From 180936697522759404c461f2874fea45615608ea Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 4 May 2026 21:07:10 -0600 Subject: [PATCH 001/292] Add Memory Mode foundation Add wiki/memories, CLI remember/recall commands, and MCP remember_memory/recall_memory tools for local agent memories. Update the demo, docs, installer instructions, and graph/dashboard handling so Link presents as local agent memory rather than only a wiki. Add tests for memory page creation, recall, MCP memory tools, demo snapshots, and installer instruction handling. --- CHANGELOG.md | 5 + LINK.md | 80 +++- README.md | 29 +- integrations/_shared/instructions.sh | 5 +- .../_shared/link-instructions-project.md | 6 +- integrations/_shared/link-instructions.md | 6 +- integrations/_shared/scaffold.sh | 4 +- integrations/antigravity/uninstall.sh | 2 +- integrations/claude-code/uninstall.sh | 2 +- integrations/codex/uninstall.sh | 2 +- integrations/copilot/uninstall.sh | 2 +- integrations/vscode/install.sh | 3 +- integrations/vscode/uninstall.sh | 3 +- link.py | 394 +++++++++++++++++- mcp_package/README.md | 6 +- mcp_package/link_mcp/server.py | 317 +++++++++++++- mcp_package/pyproject.toml | 4 +- mcp_package/server.json | 2 +- serve.py | 12 +- tests/test_demo_snapshot.py | 18 +- tests/test_link_cli.py | 68 +++ tests/test_mcp_contract.py | 33 +- tests/test_serve.py | 1 + wiki/memories/.gitkeep | 1 + 24 files changed, 945 insertions(+), 60 deletions(-) create mode 100644 wiki/memories/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ace5c..cf9f2c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ## [Unreleased] +### Added + +- Added Memory Mode foundation with `wiki/memories/`, `link.py remember`, `link.py recall`, and MCP `remember_memory`/`recall_memory` tools. +- Added a first-run demo memory page so Link presents as local agent memory, not only a wiki. + ## [1.0.7] - 2026-05-04 ### Fixed diff --git a/LINK.md b/LINK.md index e4f11ee..a2441e2 100644 --- a/LINK.md +++ b/LINK.md @@ -1,6 +1,6 @@ -# Link — Personal Knowledge Wiki +# Link — Local Agent Memory -You are the maintainer of a personal knowledge wiki called **Link**. Your job is to read raw sources, compile them into structured Wikipedia-style articles, maintain cross-references, and keep the wiki healthy over time. The human curates sources and asks questions. You do everything else. +You are the maintainer of local agent memory called **Link**. Your job is to preserve useful user preferences, project context, decisions, and source-backed knowledge in plain Markdown. The wiki is the storage format; durable local memory is the product. ## Architecture @@ -15,6 +15,7 @@ link/ │ ├── sources/ ← one summary page per ingested source │ ├── concepts/ ← concept/topic articles │ ├── entities/ ← people, orgs, projects, tools +│ ├── memories/ ← user preferences, decisions, project facts │ ├── comparisons/ ← side-by-side analyses │ └── explorations/ ← filed-back query results ├── serve.py ← local Wikipedia-style web viewer @@ -159,6 +160,40 @@ Who or what is this entity? What are they known for? Why do they matter in this - [[source-page-1]] ``` +### Memory Page (`wiki/memories/`) + +Use memory pages for durable user preferences, project decisions, stable facts about the user's work, and context agents should recall across sessions. These are directly captured memories, not neutral encyclopedia articles. + +```markdown +--- +type: memory +title: "Short Memory Title" +memory_type: preference | decision | project | fact | note +scope: user | project | global +status: active | stale | archived +date_captured: "2026-04-09T14:30:00Z" +source: "manual | conversation | mcp | raw/source.md" +tags: [memory, relevant-tag] +--- + +# Short Memory Title + +> **TLDR:** One sentence explaining what future agents should remember. + +## Memory + +The durable memory, written clearly enough for a future agent to use without rereading the original chat. + +## Use This When + +- Situation where this memory should affect future work. +- Another situation where this memory is relevant. + +## Source + +Where the memory came from and why it is trustworthy. +``` + ### Comparison Page (`wiki/comparisons/`) ```markdown @@ -234,7 +269,22 @@ How the answer was derived. Which pages were consulted. What connections were ma ## Operations -### 1. Ingest +### 1. Remember + +When the human says to remember something, capture it as local memory. Prefer the built-in command when `link.py` is available: + +```bash +python3 link.py remember "User prefers release/* branches for Link work." . --type preference --scope project --tags git,release +``` + +Rules: +- Only save memories the human explicitly asks to remember or confirms should be remembered. +- Keep memories specific and actionable. "User likes quality" is too vague; "User prefers release/* branches over codex/* branches" is useful. +- Use `memory_type: preference` for user preferences, `decision` for choices made, `project` for project context, `fact` for stable facts, and `note` for everything else. +- Use `scope: user` for broad personal preferences, `project` for the current project, and `global` for agent-wide principles. +- Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. + +### 2. Ingest When the human adds a new source to `raw/` and asks you to process it: @@ -270,19 +320,20 @@ When the human adds a new source to `raw/` and asks you to process it: - For tweets/posts as images: extract the text, author, and key claims. - Link extracted concepts to existing wiki pages, same as text sources. -### 2. Query +### 3. Query When the human asks a question: -1. **If `serve.py` is running:** call `GET /api/context?topic=` — returns the best matching page plus all related pages via graph traversal in one call. This is faster and uses fewer tokens than reading index.md manually. -2. **If server is not running:** read `wiki/index.md` to find relevant pages (check `also:` aliases for matches), then check `wiki/_backlinks.json` for pages that reference the topic. -3. Read the relevant pages and synthesize an answer. -4. Cite your sources with [[wiki-links]]. -5. Ask the human: "Want me to file this?" Answers that are comparisons should file as comparison pages, not explorations. Match the result to the right page type. -6. If yes, create a page in the appropriate directory following its template. -7. Append to `wiki/log.md`. +1. If the question may depend on user preferences, project decisions, or personal context, run `python3 link.py recall "" .` first or call MCP `recall_memory`. +2. **If `serve.py` is running:** call `GET /api/context?topic=` — returns the best matching page plus all related pages via graph traversal in one call. This is faster and uses fewer tokens than reading index.md manually. +3. **If server is not running:** read `wiki/index.md` to find relevant pages (check `also:` aliases for matches), then check `wiki/_backlinks.json` for pages that reference the topic. +4. Read the relevant pages and synthesize an answer. +5. Cite your sources with [[wiki-links]]. +6. Ask the human: "Want me to file this?" Answers that are comparisons should file as comparison pages, not explorations. Match the result to the right page type. +7. If yes, create a page in the appropriate directory following its template. +8. Append to `wiki/log.md`. -### 3. Lint +### 4. Lint When the human asks you to health-check the wiki (or periodically on your own): @@ -316,7 +367,7 @@ For each finding, suggest a specific action. Then ask the human which ones to ex Rebuild `wiki/_backlinks.json` after executing fixes. Prefer `python3 link.py rebuild-backlinks .` when `link.py` is available; otherwise call `GET /api/rebuild-backlinks` on the local server or rebuild manually. Append lint results to `wiki/log.md`. -### 4. Research +### 5. Research When the human wants to find or capture new source material for the wiki. Research has three modes based on where the material comes from. @@ -378,6 +429,9 @@ When the human wants to find or capture new source material for the wiki. Resear ### Category B - [[example-person]] — One-line description. growing · 4 sources +### memories +- [[example-preference]] — One-line memory summary. preference · user + ### Category C - [[example-project]] — One-line description. seed · 1 source diff --git a/README.md b/README.md index 33b5fe5..ac3b62f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Open: - `http://localhost:3000` - `http://localhost:3000/graph` -The demo shows the full loop: raw notes, source pages, concept pages, backlinks, graph context, search, and MCP-ready retrieval. +The demo shows the full loop: local memories, raw notes, source pages, concept pages, backlinks, graph context, search, and MCP-ready retrieval. Check the demo: @@ -89,7 +89,16 @@ Check what is pending: python3 ~/link/link.py ingest-status ~/link ``` -### 3. Ask your agent to ingest it +### 3. Save one memory + +Use direct memories for preferences, decisions, and project context future agents should recall: + +```bash +python3 ~/link/link.py remember "I am testing Link as local personal memory for agents." ~/link --type preference --scope user --tags onboarding +python3 ~/link/link.py recall "local personal memory" ~/link +``` + +### 4. Ask your agent to ingest it In your agent chat: @@ -99,7 +108,7 @@ ingest raw/first-memory.md into Link The agent reads `~/link/LINK.md`, creates a source page under `wiki/sources/`, creates or updates concept/entity pages, updates `wiki/index.md`, appends `wiki/log.md`, and rebuilds backlinks. -### 4. Verify the loop +### 5. Verify the loop ```bash python3 ~/link/link.py doctor ~/link --fix @@ -203,6 +212,7 @@ Release flow details are lower in this document. | `raw/` | Immutable sources: notes, papers, articles, transcripts, images, PDFs. | | `wiki/` | Agent-maintained Markdown memory compiled from sources. | | Source pages | One page per ingested source, stored under `wiki/sources/`. | +| Memory pages | Directly captured preferences, decisions, facts, and project context under `wiki/memories/`. | | Concept/entity pages | Synthesized knowledge pages with source citations and confidence tags. | | `_backlinks.json` | Reverse and forward link index used by search, graph, HTTP API, and MCP. | | `log.md` | Append-only audit trail of ingest, query, lint, and maintenance operations. | @@ -224,6 +234,13 @@ Ask your agent: ingest raw/notes.md into Link ``` +Remember preferences and decisions directly: + +```bash +python3 ~/link/link.py remember "User prefers release/* branches for Link work." ~/link --type preference --scope project +python3 ~/link/link.py recall "branch preference" ~/link +``` + Maintain the wiki: ```bash @@ -249,6 +266,8 @@ Obsidian also works: open the `wiki/` folder as a vault. |---------|-------------| | `python3 link.py demo` | Create `./link-demo` with a pre-ingested sample wiki. | | `python3 link.py ingest-status ` | Show pending raw files and graph index status. | +| `python3 link.py remember "text" ` | Save a local agent memory under `wiki/memories/`. | +| `python3 link.py recall "query" ` | Search local agent memories first. | | `python3 link.py doctor ` | Check structure, graph health, source hygiene, and secret-looking content. | | `python3 link.py doctor --fix` | Create missing structure and repair backlinks safely. | | `python3 link.py rebuild-backlinks ` | Regenerate `wiki/_backlinks.json`. | @@ -263,13 +282,15 @@ Available tools: | Tool | Description | |------|-------------| | `search_wiki` | Ranked search by title, alias, tag, and full text. Returns scores and snippets. | +| `recall_memory` | Search durable local memory pages for preferences, decisions, and project context. | +| `remember_memory` | Save an explicit user-approved memory under `wiki/memories/`. | | `get_context` | Primary tool. Returns the best page plus inbound and forward graph neighbors. | | `get_pages` | Lists pages with metadata. Filter by category, type, or maturity. | | `get_backlinks` | Returns inbound and forward links for one page. | | `get_graph` | Returns all nodes and edges for graph reasoning. | | `rebuild_backlinks` | Rebuilds `_backlinks.json` after ingest or maintenance. | -Use `get_context` for answering questions. It gives the agent the primary page plus its graph neighborhood in one call. +Use `recall_memory` first when an answer depends on preferences, decisions, or project context. Use `get_context` for source-backed topic answers; it gives the agent the primary page plus its graph neighborhood in one call. ## HTTP API diff --git a/integrations/_shared/instructions.sh b/integrations/_shared/instructions.sh index 2c4d10e..8a7a133 100644 --- a/integrations/_shared/instructions.sh +++ b/integrations/_shared/instructions.sh @@ -14,13 +14,14 @@ from pathlib import Path target = Path(os.environ["LINK_TARGET"]).expanduser() source = Path(os.environ["LINK_SOURCE"]).read_text(encoding="utf-8").rstrip() -header = "## Link — Personal Knowledge Wiki" +headers = ["## Link — Local Agent Memory", "## Link — Personal Knowledge Wiki"] existing = "" if target.exists(): existing = target.read_text(encoding="utf-8", errors="replace") -pattern = re.compile(rf"(^|\n){re.escape(header)}\n.*?(?=\n## |\Z)", re.DOTALL) +header_pattern = "|".join(re.escape(header) for header in headers) +pattern = re.compile(rf"(^|\n)(?:{header_pattern})\n.*?(?=\n## |\Z)", re.DOTALL) match = pattern.search(existing) if match: prefix = "\n" if match.group(1) else "" diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index 194cb96..5043ffc 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -1,7 +1,7 @@ -## Link — Personal Knowledge Wiki +## Link — Local Agent Memory -This project has a Link wiki. Raw sources in `raw/`, compiled wiki in `wiki/`. +This project has a Link wiki. Raw sources live in `raw/`, compiled wiki pages in `wiki/`, and direct memories in `wiki/memories/`. -When the user says **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `LINK.md` for instructions and follow the protocol. +When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `LINK.md` for instructions and follow the protocol. Otherwise, don't interfere — just be a normal assistant. diff --git a/integrations/_shared/link-instructions.md b/integrations/_shared/link-instructions.md index 32d3ef3..63a137f 100644 --- a/integrations/_shared/link-instructions.md +++ b/integrations/_shared/link-instructions.md @@ -1,7 +1,7 @@ -## Link — Personal Knowledge Wiki +## Link — Local Agent Memory -A personal knowledge wiki lives at `~/link/`. It has raw sources in `~/link/raw/` and compiled wiki pages in `~/link/wiki/`. +Local agent memory lives at `~/link/`. It has raw sources in `~/link/raw/`, compiled wiki pages in `~/link/wiki/`, and direct memories in `~/link/wiki/memories/`. -When the user says **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `~/link/LINK.md` for instructions and follow the protocol. Use terminal commands to access `~/link/` since it's outside the workspace. +When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `~/link/LINK.md` for instructions and follow the protocol. Use terminal commands to access `~/link/` since it's outside the workspace. Otherwise, don't interfere — just be a normal assistant. diff --git a/integrations/_shared/scaffold.sh b/integrations/_shared/scaffold.sh index f7f0f2e..e62a086 100755 --- a/integrations/_shared/scaffold.sh +++ b/integrations/_shared/scaffold.sh @@ -60,7 +60,7 @@ cp "$LINK_ROOT/.linkignore" "$TARGET_DIR/.linkignore" # ── Wiki structure: only on fresh install ──────────────────────────── # Never overwrite wiki data (index.md, log.md, _backlinks.json, page files). if [ "$IS_UPDATE" = false ]; then - for dir in raw wiki/sources wiki/concepts wiki/entities wiki/comparisons wiki/explorations; do + for dir in raw wiki/sources wiki/concepts wiki/entities wiki/memories wiki/comparisons wiki/explorations; do mkdir -p "$TARGET_DIR/$dir" touch "$TARGET_DIR/$dir/.gitkeep" done @@ -83,7 +83,7 @@ if [ "$IS_UPDATE" = false ]; then echo " Wiki structure created at $TARGET_DIR" else # On update: ensure directory structure exists (in case new dirs were added) - for dir in raw wiki/sources wiki/concepts wiki/entities wiki/comparisons wiki/explorations; do + for dir in raw wiki/sources wiki/concepts wiki/entities wiki/memories wiki/comparisons wiki/explorations; do mkdir -p "$TARGET_DIR/$dir" done fi diff --git a/integrations/antigravity/uninstall.sh b/integrations/antigravity/uninstall.sh index e570dca..a867655 100755 --- a/integrations/antigravity/uninstall.sh +++ b/integrations/antigravity/uninstall.sh @@ -15,7 +15,7 @@ if [ ! -f "$TARGET" ]; then echo "No $TARGET found"; exit 0; fi python3 -c " import re, os text = open('$TARGET').read() -cleaned = re.sub(r'\n*## Link — Personal Knowledge Wiki\n.*?(?=\n## |\Z)', '', text, flags=re.DOTALL).rstrip() +cleaned = re.sub(r'\n*## Link — (?:Local Agent Memory|Personal Knowledge Wiki)\n.*?(?=\n## |\Z)', '', text, flags=re.DOTALL).rstrip() if cleaned: open('$TARGET', 'w').write(cleaned + '\n') print('Link section removed from $TARGET') diff --git a/integrations/claude-code/uninstall.sh b/integrations/claude-code/uninstall.sh index 4ea0438..8d62ed1 100755 --- a/integrations/claude-code/uninstall.sh +++ b/integrations/claude-code/uninstall.sh @@ -15,7 +15,7 @@ if [ ! -f "$TARGET" ]; then echo "No $TARGET found"; exit 0; fi python3 -c " import re, os text = open('$TARGET').read() -cleaned = re.sub(r'\n*## Link — Personal Knowledge Wiki\n.*?(?=\n## |\Z)', '', text, flags=re.DOTALL).rstrip() +cleaned = re.sub(r'\n*## Link — (?:Local Agent Memory|Personal Knowledge Wiki)\n.*?(?=\n## |\Z)', '', text, flags=re.DOTALL).rstrip() if cleaned: open('$TARGET', 'w').write(cleaned + '\n') print('Link section removed from $TARGET') diff --git a/integrations/codex/uninstall.sh b/integrations/codex/uninstall.sh index 0d0b8a5..7b51baf 100755 --- a/integrations/codex/uninstall.sh +++ b/integrations/codex/uninstall.sh @@ -15,7 +15,7 @@ if [ ! -f "$TARGET" ]; then echo "No $TARGET found"; exit 0; fi python3 -c " import re, os text = open('$TARGET').read() -cleaned = re.sub(r'\n*## Link — Personal Knowledge Wiki\n.*?(?=\n## |\Z)', '', text, flags=re.DOTALL).rstrip() +cleaned = re.sub(r'\n*## Link — (?:Local Agent Memory|Personal Knowledge Wiki)\n.*?(?=\n## |\Z)', '', text, flags=re.DOTALL).rstrip() if cleaned: open('$TARGET', 'w').write(cleaned + '\n') print('Link section removed from $TARGET') diff --git a/integrations/copilot/uninstall.sh b/integrations/copilot/uninstall.sh index 9fd5815..2f7bbad 100755 --- a/integrations/copilot/uninstall.sh +++ b/integrations/copilot/uninstall.sh @@ -8,7 +8,7 @@ if [ ! -f "$TARGET" ]; then echo "No $TARGET found"; exit 0; fi python3 -c " import re, os text = open('$TARGET').read() -cleaned = re.sub(r'\n*## Link — Personal Knowledge Wiki\n.*?(?=\n## |\Z)', '', text, flags=re.DOTALL).rstrip() +cleaned = re.sub(r'\n*## Link — (?:Local Agent Memory|Personal Knowledge Wiki)\n.*?(?=\n## |\Z)', '', text, flags=re.DOTALL).rstrip() if cleaned: open('$TARGET', 'w').write(cleaned + '\n') print('Link section removed from $TARGET') diff --git a/integrations/vscode/install.sh b/integrations/vscode/install.sh index 11590f7..fd8c13c 100755 --- a/integrations/vscode/install.sh +++ b/integrations/vscode/install.sh @@ -38,7 +38,8 @@ if not isinstance(instructions, list): instructions = [] instructions = [ i for i in instructions - if '## Link — Personal Knowledge Wiki' not in i.get('text', '') + if '## Link — Local Agent Memory' not in i.get('text', '') + and '## Link — Personal Knowledge Wiki' not in i.get('text', '') and 'Link, an LLM-maintained knowledge wiki' not in i.get('text', '') ] instructions.append({'text': instructions_text}) diff --git a/integrations/vscode/uninstall.sh b/integrations/vscode/uninstall.sh index 56ff332..9f4f708 100755 --- a/integrations/vscode/uninstall.sh +++ b/integrations/vscode/uninstall.sh @@ -11,7 +11,8 @@ settings = json.load(open('$TARGET')) instructions = settings.get('github.copilot.chat.codeGeneration.instructions', []) filtered = [ i for i in instructions - if '## Link — Personal Knowledge Wiki' not in i.get('text', '') + if '## Link — Local Agent Memory' not in i.get('text', '') + and '## Link — Personal Knowledge Wiki' not in i.get('text', '') and 'Link, an LLM-maintained knowledge wiki' not in i.get('text', '') ] if len(filtered) < len(instructions): diff --git a/link.py b/link.py index 300ef5c..58d95c5 100644 --- a/link.py +++ b/link.py @@ -5,6 +5,8 @@ python link.py demo [target] python link.py doctor [target] python link.py ingest-status [target] + python link.py remember "memory text" [target] + python link.py recall "query" [target] python link.py rebuild-backlinks [target] python link.py verify-mcp [target] """ @@ -17,6 +19,7 @@ import shutil import subprocess import sys +from datetime import datetime, timezone from pathlib import Path from typing import Callable @@ -81,6 +84,8 @@ ".whl", ".zip", } +MEMORY_TYPES = ("preference", "decision", "project", "fact", "note") +MEMORY_SCOPES = ("user", "project", "global") DEMO_FILES: dict[str, str] = { @@ -487,6 +492,35 @@ - [[agent-memory-session]] - [[local-release-notes]] +""", + "wiki/memories/prefer-local-personal-memory.md": """--- +type: memory +title: "Prefer local personal memory" +memory_type: preference +scope: user +status: active +date_captured: "2026-05-04T00:00:00Z" +tags: [memory, agents, local-first] +aliases: ["local personal memory", "agent personal memory"] +--- + +# Prefer local personal memory + +> **TLDR:** The user wants Link to be local personal memory for agents, with the wiki as the inspectable storage format. + +## Memory + +The user wants [[link]] to feel like local personal memory for agents rather than only a wiki. Agents should remember user preferences, project context, decisions, and why those memories exist. + +## Use This When + +- Positioning Link in product copy or onboarding. +- Deciding whether a feature should prioritize [[agent-memory]] workflows over generic note management. +- Explaining why [[local-first-software]] matters for personal agent memory. + +## Source + +Captured as demo product intent for the first-run wiki. """, "wiki/explorations/why-link-helps-agents.md": """--- type: exploration @@ -520,7 +554,7 @@ """, "wiki/index.md": """# Link Demo Wiki Index -> Last updated: 2026-05-02 | 10 pages | 3 sources +> Last updated: 2026-05-02 | 11 pages | 3 sources ## Categories @@ -534,6 +568,9 @@ ### entities - [[link]] - Local-first wiki and MCP memory server for agents. growing - 2 sources - also: Link MCP +### memories +- [[prefer-local-personal-memory]] - User preference that Link should behave as local personal memory for agents. preference · user + ### sources - [[agent-memory-session]] - Demo note on durable project context. high - [[transformer-reading-notes]] - Demo note connecting transformers, retrieval, and memory. high @@ -546,7 +583,7 @@ | Date | Operation | Pages Touched | |------|-----------|---------------| -| 2026-05-02 | demo: create first-run sample wiki | 10 pages | +| 2026-05-02 | demo: create first-run sample wiki | 11 pages | """, "wiki/log.md": """# Link Demo Wiki Log @@ -568,9 +605,10 @@ - Created: concepts/local-first-software.md - Created: concepts/knowledge-graph.md - Created: entities/link.md +- Created: memories/prefer-local-personal-memory.md - Created: explorations/why-link-helps-agents.md - Rebuilt: wiki/_backlinks.json -- Pages touched: 10 +- Pages touched: 11 --- """, @@ -666,6 +704,252 @@ def _resolve_wiki_dir(target: Path) -> Path: return target / "wiki" +def _utc_timestamp() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _slugify(value: str, fallback: str = "memory") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or fallback + + +def _frontmatter_string(value: str) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"') + + +def _csv_values(raw: str | None) -> list[str]: + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def _meta_tags(value: object) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [item.strip().strip("\"'") for item in _csv_values(str(value).strip("[]"))] + + +def _yaml_list(values: list[str]) -> str: + return "[" + ", ".join(values) + "]" + + +def _memory_title(text: str, explicit_title: str | None = None) -> str: + if explicit_title and explicit_title.strip(): + return explicit_title.strip() + first_line = next((line.strip() for line in text.splitlines() if line.strip()), "Memory") + first_sentence = re.split(r"(?<=[.!?])\s+", first_line, maxsplit=1)[0].strip() + if len(first_sentence) <= 70: + return first_sentence.rstrip(".") + return first_sentence[:67].rstrip() + "..." + + +def _unique_page_path(directory: Path, slug: str) -> Path: + candidate = directory / f"{slug}.md" + index = 2 + while candidate.exists(): + candidate = directory / f"{slug}-{index}.md" + index += 1 + return candidate + + +def _extract_tldr(body: str) -> str: + match = re.search(r">\s*\*\*TLDR:\*\*\s*(.+)", body) + return match.group(1).strip() if match else "" + + +def _first_body_snippet(body: str) -> str: + for line in body.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and not stripped.startswith(">"): + return stripped[:200] + return "" + + +def _memory_records(wiki_dir: Path) -> list[dict[str, object]]: + memories_dir = wiki_dir / "memories" + if not memories_dir.exists(): + return [] + records: list[dict[str, object]] = [] + for path in sorted(memories_dir.rglob("*.md")): + if path.name.startswith("."): + continue + text = path.read_text(encoding="utf-8", errors="replace") + meta, body = _parse_frontmatter(text) + title = meta.get("title") or _memory_title(body) + records.append({ + "name": path.stem, + "path": f"wiki/{path.relative_to(wiki_dir).as_posix()}", + "title": title, + "memory_type": meta.get("memory_type", meta.get("type", "memory")), + "scope": meta.get("scope", ""), + "status": meta.get("status", ""), + "tags": _meta_tags(meta.get("tags", "")), + "tldr": _extract_tldr(body), + "snippet": _first_body_snippet(body), + "body": body, + }) + return records + + +def _score_memory(record: dict[str, object], query: str) -> int: + q = query.lower().strip() + tokens = [token for token in re.split(r"\W+", q) if len(token) >= 3] + title = str(record.get("title", "")).lower() + tldr = str(record.get("tldr", "")).lower() + body = str(record.get("body", "")).lower() + tags = " ".join(str(tag).lower() for tag in record.get("tags", [])) + score = 0 + if q and q in title: + score += 20 + if q and q in tldr: + score += 12 + if q and q in tags: + score += 8 + if q and q in body: + score += 4 + for token in tokens: + if token in title: + score += 6 + if token in tldr: + score += 4 + if token in tags: + score += 3 + if token in body: + score += 1 + return score + + +def _recall_memories(wiki_dir: Path, query: str, limit: int = 10) -> list[dict[str, object]]: + q = query.strip() + if not q: + return [] + scored: list[tuple[int, dict[str, object]]] = [] + for record in _memory_records(wiki_dir): + score = _score_memory(record, q) + if score > 0: + slim = {key: value for key, value in record.items() if key != "body"} + slim["score"] = score + scored.append((score, slim)) + scored.sort(key=lambda item: (-item[0], str(item[1]["title"]).lower())) + return [record for _, record in scored[:limit]] + + +def _update_memory_index(index_path: Path, page_name: str, title: str, summary: str, memory_type: str, scope: str) -> None: + if not index_path.exists(): + _write_default_index(index_path) + text = index_path.read_text(encoding="utf-8", errors="replace") + if f"[[{page_name}]]" in text: + return + entry = f"- [[{page_name}]] - {summary} {memory_type} · {scope}\n" + if "### memories" in text: + pattern = re.compile(r"(### memories\n)(.*?)(?=\n### |\n## Recent|\Z)", flags=re.DOTALL) + text = pattern.sub(lambda m: m.group(1) + m.group(2).rstrip() + "\n" + entry, text, count=1) + elif "\n## Recent" in text: + text = text.replace("\n## Recent", f"\n### memories\n{entry}\n## Recent", 1) + else: + text = text.rstrip() + f"\n\n### memories\n{entry}" + index_path.write_text(text, encoding="utf-8") + + +def _append_log(wiki_dir: Path, timestamp: str, operation: str, description: str, lines: list[str]) -> None: + log_path = wiki_dir / "log.md" + if not log_path.exists(): + _write_default_log(log_path) + entry = [f"## [{timestamp}] {operation} | {description}", ""] + entry.extend(f"- {line}" for line in lines) + entry.extend(["", "---", ""]) + with log_path.open("a", encoding="utf-8") as handle: + handle.write("\n".join(entry)) + + +def _write_memory_page( + target: Path, + text: str, + title: str | None = None, + memory_type: str = "note", + scope: str = "user", + tags: str | None = None, + source: str = "manual", + timestamp: str | None = None, +) -> dict[str, object]: + target = target.expanduser().resolve() + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + raise FileNotFoundError(f"missing wiki directory: {wiki_dir}") + if memory_type not in MEMORY_TYPES: + raise ValueError(f"memory_type must be one of: {', '.join(MEMORY_TYPES)}") + if scope not in MEMORY_SCOPES: + raise ValueError(f"scope must be one of: {', '.join(MEMORY_SCOPES)}") + + timestamp = timestamp or _utc_timestamp() + clean_text = text.strip() + memory_title = _memory_title(clean_text, title) + summary = clean_text.splitlines()[0].strip() + if len(summary) > 180: + summary = summary[:177].rstrip() + "..." + memories_dir = wiki_dir / "memories" + memories_dir.mkdir(parents=True, exist_ok=True) + page_path = _unique_page_path(memories_dir, _slugify(memory_title)) + page_name = page_path.stem + tag_values = ["memory", memory_type] + for tag in _csv_values(tags): + slug_tag = _slugify(tag, fallback="") + if slug_tag and slug_tag not in tag_values: + tag_values.append(slug_tag) + + page = f"""--- +type: memory +title: "{_frontmatter_string(memory_title)}" +memory_type: {memory_type} +scope: {scope} +status: active +date_captured: "{timestamp}" +source: "{_frontmatter_string(source)}" +tags: {_yaml_list(tag_values)} +--- + +# {memory_title} + +> **TLDR:** {summary} + +## Memory + +{clean_text} + +## Use This When + +- An agent needs relevant {scope} context for future work. +- A future answer depends on this {memory_type}. + +## Source + +{source} +""" + page_path.write_text(page, encoding="utf-8") + _update_memory_index(wiki_dir / "index.md", page_name, memory_title, summary, memory_type, scope) + _append_log( + wiki_dir, + timestamp, + "remember", + memory_title, + [ + f"Created: memories/{page_path.name}", + f"Type: {memory_type}", + f"Scope: {scope}", + ], + ) + backlinks = _build_backlinks(wiki_dir) + (wiki_dir / "_backlinks.json").write_text(json.dumps(backlinks, indent=2) + "\n", encoding="utf-8") + return { + "created": True, + "name": page_name, + "path": f"wiki/memories/{page_path.name}", + "title": memory_title, + "memory_type": memory_type, + "scope": scope, + } + + def _normalize_link_index(data: dict[str, dict[str, list[str]]]) -> dict[str, dict[str, list[str]]]: normalized: dict[str, dict[str, list[str]]] = {"backlinks": {}, "forward": {}} for section in ("backlinks", "forward"): @@ -915,6 +1199,7 @@ def _required_paths(target: Path) -> list[Path]: wiki_dir / "sources", wiki_dir / "concepts", wiki_dir / "entities", + wiki_dir / "memories", wiki_dir / "comparisons", wiki_dir / "explorations", ] @@ -1151,6 +1436,79 @@ def rebuild_backlinks(target: Path) -> int: return 0 +def remember( + target: Path, + text: str, + title: str | None = None, + memory_type: str = "note", + scope: str = "user", + tags: str | None = None, + source: str = "manual", + json_output: bool = False, +) -> int: + if not text or not text.strip(): + print("Memory text is required", file=sys.stderr) + return 1 + try: + result = _write_memory_page( + target, + text, + title=title, + memory_type=memory_type, + scope=scope, + tags=tags, + source=source, + ) + except (FileNotFoundError, ValueError) as exc: + print(f"Could not remember: {exc}", file=sys.stderr) + return 1 + + if json_output: + print(json.dumps(result, indent=2)) + return 0 + + print("Memory saved") + print(f"Title: {result['title']}") + print(f"Path: {result['path']}") + print(f"Type: {result['memory_type']}") + print(f"Scope: {result['scope']}") + print("") + print("Next:") + print(f" python3 link.py recall \"{result['title']}\" .") + return 0 + + +def recall(target: Path, query: str, limit: int = 10, json_output: bool = False) -> int: + target = target.expanduser().resolve() + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + results = _recall_memories(wiki_dir, query, limit=limit) + + if json_output: + print(json.dumps({"query": query, "count": len(results), "memories": results}, indent=2)) + return 0 + + print(f"Link memory recall: {query}") + print("") + if not results: + print("No matching memories found.") + print("") + print("Next:") + print(" Add one: python3 link.py remember \"Memory to keep\" .") + return 0 + + print(f"{len(results)} memor{'y' if len(results) == 1 else 'ies'}") + for record in results: + print(f"- {record['title']} ({record['memory_type']} · {record['scope']})") + print(f" {record['path']}") + summary = record.get("tldr") or record.get("snippet") + if summary: + print(f" {summary}") + return 0 + + def _check_link_mcp_import(python_cmd: str) -> dict[str, object]: code = ( "import json, link_mcp; " @@ -1303,6 +1661,7 @@ def create_demo(target: Path, force: bool = False) -> None: "wiki/sources", "wiki/concepts", "wiki/entities", + "wiki/memories", "wiki/comparisons", "wiki/explorations", ): @@ -1347,6 +1706,22 @@ def main(argv: list[str] | None = None) -> int: ingest_status_cmd.add_argument("target", nargs="?", default=".") ingest_status_cmd.add_argument("--json", action="store_true", help="print machine-readable status") + remember_cmd = sub.add_parser("remember", help="save a local agent memory") + remember_cmd.add_argument("text", help="memory text to save") + remember_cmd.add_argument("target", nargs="?", default=".") + remember_cmd.add_argument("--title", default=None, help="memory page title") + remember_cmd.add_argument("--type", choices=MEMORY_TYPES, default="note", dest="memory_type") + remember_cmd.add_argument("--scope", choices=MEMORY_SCOPES, default="user") + remember_cmd.add_argument("--tags", default=None, help="comma-separated tags") + remember_cmd.add_argument("--source", default="manual", help="where this memory came from") + remember_cmd.add_argument("--json", action="store_true", help="print machine-readable status") + + recall_cmd = sub.add_parser("recall", help="search local agent memories") + recall_cmd.add_argument("query", help="memory query") + recall_cmd.add_argument("target", nargs="?", default=".") + recall_cmd.add_argument("--limit", type=int, default=10) + recall_cmd.add_argument("--json", action="store_true", help="print machine-readable results") + rebuild_cmd = sub.add_parser("rebuild-backlinks", help="rebuild wiki/_backlinks.json") rebuild_cmd.add_argument("target", nargs="?", default=".") @@ -1363,6 +1738,19 @@ def main(argv: list[str] | None = None) -> int: return doctor(Path(args.target), fix=args.fix) if args.command == "ingest-status": return ingest_status(Path(args.target), json_output=args.json) + if args.command == "remember": + return remember( + Path(args.target), + args.text, + title=args.title, + memory_type=args.memory_type, + scope=args.scope, + tags=args.tags, + source=args.source, + json_output=args.json, + ) + if args.command == "recall": + return recall(Path(args.target), args.query, limit=args.limit, json_output=args.json) if args.command == "rebuild-backlinks": return rebuild_backlinks(Path(args.target)) if args.command == "verify-mcp": diff --git a/mcp_package/README.md b/mcp_package/README.md index 0df0a79..bf0157b 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -2,7 +2,7 @@ -MCP server for the [Link](https://github.com/gowtham0992/link) personal knowledge wiki. Exposes your wiki as MCP tools — search, query context, and traverse the knowledge graph without reading files directly. +MCP server for [Link](https://github.com/gowtham0992/link), local personal memory for agents. Exposes memories and wiki context as MCP tools so agents can recall preferences, decisions, project context, sources, and graph neighborhoods without reading files directly. Listed on the [official MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.gowtham0992/link`. @@ -81,6 +81,8 @@ Custom wiki path: | Tool | Description | |------|-------------| +| `recall_memory(query, limit?)` | Search durable local memories for preferences, decisions, and project context. | +| `remember_memory(memory, title?, memory_type?, scope?, tags?, source?)` | Save an explicit user-approved local memory under `wiki/memories/`. | | `search_wiki(query, limit?)` | Ranked search — title (20pts), alias (8pts), tag (5pts), fulltext (2pts). Returns scores + snippets. | | `get_context(topic)` | **Primary tool.** Best matching page (full content) + inbound/forward graph links in one call. | | `get_pages(category?, type?, maturity?)` | All pages with metadata. Filter by category, type, or maturity. | @@ -88,7 +90,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -**Use `get_context` for answering questions** — one call returns the primary page plus all related pages via graph traversal. Eliminates the token waste of reading index.md every session. +Use `recall_memory` first for user preferences, decisions, and project context. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 313858d..6417c39 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -25,6 +25,7 @@ """ from __future__ import annotations import argparse, json, re, sys +from datetime import datetime, timezone from pathlib import Path # ── Resolve wiki directory ──────────────────────────────────────────── @@ -51,11 +52,11 @@ mcp = FastMCP( "link", instructions=( - "Link is a personal knowledge wiki. Use search_wiki to find pages, " - "get_context to retrieve a topic with its full graph neighborhood, " - "and get_pages to browse all pages. Always prefer get_context over " - "reading files directly — it returns the primary page plus related " - "pages via graph traversal in one call." + "Link is local personal memory for agents. Use recall_memory for " + "user preferences, decisions, and project context; use search_wiki to " + "find general pages; use get_context to retrieve a topic with its full " + "graph neighborhood. Only call remember_memory when the user explicitly " + "asks you to remember something." ), ) @@ -63,6 +64,8 @@ _cache: dict = {} _cache_mtime: float = 0.0 MAX_TEXT_INPUT = 200 +MEMORY_TYPES = ("preference", "decision", "project", "fact", "note") +MEMORY_SCOPES = ("user", "project", "global") def _clean_text_input(value, max_len: int = MAX_TEXT_INPUT) -> str: @@ -343,6 +346,259 @@ def _get_context(topic: str) -> dict: } +def _utc_timestamp() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _slugify(value: str, fallback: str = "memory") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or fallback + + +def _frontmatter_string(value: str) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"') + + +def _csv_values(raw: str | None) -> list[str]: + if not raw: + return [] + return [item.strip() for item in raw.split(",") if item.strip()] + + +def _meta_tags(value: object) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [item.strip().strip("\"'") for item in _csv_values(str(value).strip("[]"))] + + +def _yaml_list(values: list[str]) -> str: + return "[" + ", ".join(values) + "]" + + +def _memory_title(text: str, explicit_title: str | None = None) -> str: + if explicit_title and explicit_title.strip(): + return explicit_title.strip() + first_line = next((line.strip() for line in text.splitlines() if line.strip()), "Memory") + first_sentence = re.split(r"(?<=[.!?])\s+", first_line, maxsplit=1)[0].strip() + if len(first_sentence) <= 70: + return first_sentence.rstrip(".") + return first_sentence[:67].rstrip() + "..." + + +def _unique_page_path(directory: Path, slug: str) -> Path: + candidate = directory / f"{slug}.md" + index = 2 + while candidate.exists(): + candidate = directory / f"{slug}-{index}.md" + index += 1 + return candidate + + +def _extract_tldr(body: str) -> str: + match = re.search(r">\s*\*\*TLDR:\*\*\s*(.+)", body) + return match.group(1).strip() if match else "" + + +def _first_body_snippet(body: str) -> str: + for line in body.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and not stripped.startswith(">"): + return stripped[:200] + return "" + + +def _memory_records() -> list[dict[str, object]]: + memories_dir = WIKI_DIR / "memories" + if not memories_dir.exists(): + return [] + records: list[dict[str, object]] = [] + for path in sorted(memories_dir.rglob("*.md")): + if path.name.startswith("."): + continue + text = path.read_text(encoding="utf-8", errors="replace") + meta, body = _parse_frontmatter(text) + title = meta.get("title") or _memory_title(body) + records.append({ + "name": path.stem, + "path": f"wiki/{path.relative_to(WIKI_DIR).as_posix()}", + "title": title, + "memory_type": meta.get("memory_type", meta.get("type", "memory")), + "scope": meta.get("scope", ""), + "status": meta.get("status", ""), + "tags": _meta_tags(meta.get("tags", "")), + "tldr": _extract_tldr(body), + "snippet": _first_body_snippet(body), + "body": body, + }) + return records + + +def _score_memory(record: dict[str, object], query: str) -> int: + q = query.lower().strip() + tokens = [token for token in re.split(r"\W+", q) if len(token) >= 3] + title = str(record.get("title", "")).lower() + tldr = str(record.get("tldr", "")).lower() + body = str(record.get("body", "")).lower() + tags = " ".join(str(tag).lower() for tag in record.get("tags", [])) + score = 0 + if q and q in title: + score += 20 + if q and q in tldr: + score += 12 + if q and q in tags: + score += 8 + if q and q in body: + score += 4 + for token in tokens: + if token in title: + score += 6 + if token in tldr: + score += 4 + if token in tags: + score += 3 + if token in body: + score += 1 + return score + + +def _recall_memories(query: str, limit: int = 10) -> list[dict[str, object]]: + query = _clean_text_input(query) + if not query: + return [] + scored: list[tuple[int, dict[str, object]]] = [] + for record in _memory_records(): + score = _score_memory(record, query) + if score > 0: + slim = {key: value for key, value in record.items() if key != "body"} + slim["score"] = score + scored.append((score, slim)) + scored.sort(key=lambda item: (-item[0], str(item[1]["title"]).lower())) + return [record for _, record in scored[:limit]] + + +def _update_memory_index(page_name: str, title: str, summary: str, memory_type: str, scope: str) -> None: + index_path = WIKI_DIR / "index.md" + if not index_path.exists(): + index_path.write_text( + "# Link Wiki Index\n\n" + "> Last updated: not yet ingested | 0 pages | 0 sources\n\n" + "## Categories\n\n" + "## Recent\n\n" + "| Date | Operation | Pages Touched |\n" + "|------|-----------|---------------|\n", + encoding="utf-8", + ) + text = index_path.read_text(encoding="utf-8", errors="replace") + if f"[[{page_name}]]" in text: + return + entry = f"- [[{page_name}]] - {summary} {memory_type} · {scope}\n" + if "### memories" in text: + pattern = re.compile(r"(### memories\n)(.*?)(?=\n### |\n## Recent|\Z)", flags=re.DOTALL) + text = pattern.sub(lambda m: m.group(1) + m.group(2).rstrip() + "\n" + entry, text, count=1) + elif "\n## Recent" in text: + text = text.replace("\n## Recent", f"\n### memories\n{entry}\n## Recent", 1) + else: + text = text.rstrip() + f"\n\n### memories\n{entry}" + index_path.write_text(text, encoding="utf-8") + + +def _append_log(timestamp: str, operation: str, description: str, lines: list[str]) -> None: + log_path = WIKI_DIR / "log.md" + if not log_path.exists(): + log_path.write_text("# Link Wiki Log\n\n*Append-only record of wiki operations.*\n", encoding="utf-8") + entry = [f"## [{timestamp}] {operation} | {description}", ""] + entry.extend(f"- {line}" for line in lines) + entry.extend(["", "---", ""]) + with log_path.open("a", encoding="utf-8") as handle: + handle.write("\n".join(entry)) + + +def _write_memory_page( + text: str, + title: str = "", + memory_type: str = "note", + scope: str = "user", + tags: str = "", + source: str = "mcp", +) -> dict[str, object]: + clean_text = _clean_text_input(text, max_len=4000) + if not clean_text: + raise ValueError("memory text required") + memory_type = _clean_text_input(memory_type).lower() or "note" + scope = _clean_text_input(scope).lower() or "user" + if memory_type not in MEMORY_TYPES: + raise ValueError(f"memory_type must be one of: {', '.join(MEMORY_TYPES)}") + if scope not in MEMORY_SCOPES: + raise ValueError(f"scope must be one of: {', '.join(MEMORY_SCOPES)}") + + timestamp = _utc_timestamp() + memory_title = _memory_title(clean_text, _clean_text_input(title)) + summary = clean_text.splitlines()[0].strip() + if len(summary) > 180: + summary = summary[:177].rstrip() + "..." + memories_dir = WIKI_DIR / "memories" + memories_dir.mkdir(parents=True, exist_ok=True) + page_path = _unique_page_path(memories_dir, _slugify(memory_title)) + page_name = page_path.stem + tag_values = ["memory", memory_type] + for tag in _csv_values(tags): + slug_tag = _slugify(tag, fallback="") + if slug_tag and slug_tag not in tag_values: + tag_values.append(slug_tag) + + page = f"""--- +type: memory +title: "{_frontmatter_string(memory_title)}" +memory_type: {memory_type} +scope: {scope} +status: active +date_captured: "{timestamp}" +source: "{_frontmatter_string(source)}" +tags: {_yaml_list(tag_values)} +--- + +# {memory_title} + +> **TLDR:** {summary} + +## Memory + +{clean_text} + +## Use This When + +- An agent needs relevant {scope} context for future work. +- A future answer depends on this {memory_type}. + +## Source + +{source} +""" + page_path.write_text(page, encoding="utf-8") + _update_memory_index(page_name, memory_title, summary, memory_type, scope) + _append_log( + timestamp, + "remember", + memory_title, + [ + f"Created: memories/{page_path.name}", + f"Type: {memory_type}", + f"Scope: {scope}", + ], + ) + rebuilt = json.loads(rebuild_backlinks()) + _cache.clear() + return { + "created": True, + "name": page_name, + "path": f"wiki/memories/{page_path.name}", + "title": memory_title, + "memory_type": memory_type, + "scope": scope, + "backlinks_rebuilt": bool(rebuilt.get("rebuilt")), + } + + # ── MCP Tools ───────────────────────────────────────────────────────── @mcp.tool() @@ -372,6 +628,53 @@ def search_wiki(query: str, limit: int = 20) -> str: return json.dumps({"query": query, "count": len(slim), "results": slim}, ensure_ascii=False) +@mcp.tool() +def recall_memory(query: str, limit: int = 10) -> str: + """Search local agent memory pages first. + + Use this when the user asks about preferences, decisions, project context, + or anything the agent should remember across sessions. Returns only pages + under wiki/memories/. + """ + query = _clean_text_input(query) + limit = _parse_limit(limit, default=10) + if not query: + return json.dumps({"error": "query required", "query": "", "count": 0, "memories": []}) + memories = _recall_memories(query, limit=limit) + return json.dumps({"query": query, "count": len(memories), "memories": memories}, ensure_ascii=False) + + +@mcp.tool() +def remember_memory( + memory: str, + title: str = "", + memory_type: str = "note", + scope: str = "user", + tags: str = "", + source: str = "mcp", +) -> str: + """Save a local agent memory as a Markdown page. + + Use only when the user explicitly asks you to remember something. The memory + is written under wiki/memories/, indexed, logged, and kept local. + memory_type: preference, decision, project, fact, or note. + scope: user, project, or global. + tags: optional comma-separated tags. + """ + try: + result = _write_memory_page( + memory, + title=title, + memory_type=memory_type, + scope=scope, + tags=tags, + source=source, + ) + except ValueError as exc: + return json.dumps({"created": False, "error": str(exc)}) + return json.dumps(result, ensure_ascii=False) + + @mcp.tool() def get_context(topic: str) -> str: """Get full context for a topic from the Link wiki. @@ -396,8 +699,8 @@ def get_pages(category: str = "", page_type: str = "", maturity: str = "") -> st """List all pages in the Link wiki with metadata. Optional filters: - - category: "concepts", "entities", "sources", "comparisons", "explorations" - - page_type: "concept", "entity", "source", "comparison", "exploration" + - category: "memories", "concepts", "entities", "sources", "comparisons", "explorations" + - page_type: "memory", "concept", "entity", "source", "comparison", "exploration" - maturity: "seed", "growing", "mature", "established" Returns pages with: name, title, category, type, tags, aliases, maturity, diff --git a/mcp_package/pyproject.toml b/mcp_package/pyproject.toml index 78cdd9d..c357d78 100644 --- a/mcp_package/pyproject.toml +++ b/mcp_package/pyproject.toml @@ -5,12 +5,12 @@ build-backend = "hatchling.build" [project] name = "link-mcp" version = "1.0.7" -description = "MCP server for the Link personal knowledge wiki — search, context, and graph traversal" +description = "MCP server for Link local agent memory — remember, recall, search, context, and graph traversal" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" dependencies = ["mcp>=1.0.0"] -keywords = ["mcp", "knowledge-base", "wiki", "llm", "ai"] +keywords = ["mcp", "memory", "knowledge-base", "wiki", "llm", "ai"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", diff --git a/mcp_package/server.json b/mcp_package/server.json index 802bb34..6eea6fd 100644 --- a/mcp_package/server.json +++ b/mcp_package/server.json @@ -1,7 +1,7 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.gowtham0992/link", - "description": "Personal knowledge wiki as MCP tools \u2014 search, context, graph traversal.", + "description": "Local personal memory for agents as MCP tools \u2014 remember, recall, search, context, graph traversal.", "repository": { "url": "https://github.com/gowtham0992/link", "source": "github" diff --git a/serve.py b/serve.py index 92f0b2d..b3547ac 100644 --- a/serve.py +++ b/serve.py @@ -182,7 +182,7 @@ def _page_href(name: str) -> str: def _plural_type_label(page_type: str) -> str: - irregular = {"entity": "entities"} + irregular = {"entity": "entities", "memory": "memories"} if page_type in irregular: return irregular[page_type] return page_type if page_type.endswith("s") else page_type + "s" @@ -537,7 +537,7 @@ def _render_home(): counts[t] = counts.get(t, 0) + 1 stats_items = f'
{len(pages)}pages
' - for t in ["source", "concept", "entity", "comparison", "exploration"]: + for t in ["memory", "source", "concept", "entity", "comparison", "exploration"]: if counts.get(t, 0) > 0: label = _plural_type_label(t) stats_items += f'
{counts[t]}{label}
' @@ -631,8 +631,9 @@ def _render_graph(): return _layout("Knowledge Graph", body) # Category → color mapping - cat_colors = {"concepts": "#4e79a7", "entities": "#f28e2b", "sources": "#59a14f", - "comparisons": "#e15759", "explorations": "#76b7b2", "root": "#bab0ac"} + cat_colors = {"concepts": "#4e79a7", "entities": "#f28e2b", "memories": "#edc948", + "sources": "#59a14f", "comparisons": "#e15759", + "explorations": "#76b7b2", "root": "#bab0ac"} graph_js = f""" @@ -727,6 +861,7 @@ def _layout(title, body): }} }}); + """ @@ -1643,6 +1778,7 @@ def _search_pages(q: str, limit: int = 20) -> list: "pages": _pages_cache or [], "page_index": _page_index, "fulltext": _fulltext_index, + "normalized_fulltext": _normalized_fulltext_index, "snippet_index": _snippet_index, "token_index": _token_index, "meta_token_index": _meta_token_index, @@ -1664,6 +1800,7 @@ def _get_context(topic: str) -> dict: "pages": _pages_cache or [], "page_index": _page_index, "fulltext": _fulltext_index, + "normalized_fulltext": _normalized_fulltext_index, "snippet_index": _snippet_index, "token_index": _token_index, "meta_token_index": _meta_token_index, @@ -1692,6 +1829,7 @@ def _get_graph_data() -> dict: "pages": _pages_cache or [], "page_index": _page_index, "fulltext": _fulltext_index, + "normalized_fulltext": _normalized_fulltext_index, "snippet_index": _snippet_index, "token_index": _token_index, "meta_token_index": _meta_token_index, diff --git a/tests/test_demo_snapshot.py b/tests/test_demo_snapshot.py index 29a0215..54d1674 100644 --- a/tests/test_demo_snapshot.py +++ b/tests/test_demo_snapshot.py @@ -64,6 +64,7 @@ def reset_serve_wiki(wiki_dir: Path) -> None: serve._pages_cache_mtime = 0.0 serve._page_index = {} serve._fulltext_index = {} + serve._normalized_fulltext_index = {} serve._snippet_index = {} serve._token_index = {} serve._page_map = {} @@ -119,6 +120,17 @@ def test_demo_home_shows_memories(self): self.assertIn('memories', html) self.assertIn("Prefer local personal memory", html) + def test_demo_search_matches_hyphenated_pages_with_natural_query(self): + target = self.make_demo() + reset_serve_wiki(target / "wiki") + + results = serve._search_pages("local first software") + context = serve._get_context("local first software") + + self.assertEqual(results[0]["name"], "local-first-software") + self.assertTrue(context["found"]) + self.assertEqual(context["primary"], "local-first-software") + def test_demo_profile_snapshot(self): target = self.make_demo() reset_serve_wiki(target / "wiki") diff --git a/tests/test_installers.py b/tests/test_installers.py index 7fbe212..49b54b7 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -23,6 +23,14 @@ def test_scaffold_does_not_use_break_system_packages(self): self.assertNotIn("--break-system-packages", scaffold) self.assertIn(".link-mcp-venv", scaffold) self.assertIn(".link-mcp-python", scaffold) + self.assertIn("LINK_MCP_INSTALLED=false", scaffold) + self.assertIn('[ "$LINK_MCP_INSTALLED" = true ]', scaffold) + + def test_scaffold_project_mode_uses_absolute_target(self): + scaffold = (ROOT / "integrations/_shared/scaffold.sh").read_text(encoding="utf-8") + + self.assertIn('TARGET_DIR="$(pwd)"', scaffold) + self.assertNotIn('TARGET_DIR="."', scaffold) def test_installers_read_resolved_mcp_python_marker(self): for installer in INSTALLERS: @@ -31,6 +39,13 @@ def test_installers_read_resolved_mcp_python_marker(self): self.assertIn("MCP_PYTHON", text) self.assertIn(".link-mcp-python", text) + def test_installers_print_mode_specific_next_steps(self): + for installer in INSTALLERS: + with self.subTest(installer=installer.name): + text = installer.read_text(encoding="utf-8") + self.assertIn('if [ "$MODE" = "--project" ]; then', text) + self.assertIn("View wiki: python serve.py", text) + def test_codex_and_kiro_update_existing_mcp_registration(self): codex = (ROOT / "integrations/codex/install.sh").read_text(encoding="utf-8") kiro = (ROOT / "integrations/kiro/install.sh").read_text(encoding="utf-8") diff --git a/tests/test_serve.py b/tests/test_serve.py index d6aba32..0e9ebfb 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -16,6 +16,7 @@ def reset_wiki(wiki_dir: Path) -> None: serve._pages_cache_mtime = 0.0 serve._page_index = {} serve._fulltext_index = {} + serve._normalized_fulltext_index = {} serve._snippet_index = {} serve._token_index = {} serve._page_map = {} @@ -76,9 +77,12 @@ def test_layout_handles_search_enter_key(self): self.assertIn("document.activeElement.id === 'search-input'", html) self.assertIn("window.location.href = '/search?q=' + encodeURIComponent(q);", html) + self.assertIn("data-theme-toggle", html) + self.assertIn("localStorage.getItem('link-theme')", html) def test_css_has_mobile_overflow_guards(self): - self.assertIn("html { overflow-x: hidden; }", serve.CSS) + self.assertIn("* { box-sizing: border-box; margin: 0; padding: 0; }", serve.CSS) + self.assertIn("html { overflow-x: hidden; background: var(--bg); }", serve.CSS) self.assertIn("overflow-x: hidden; overflow-wrap: anywhere", serve.CSS) self.assertIn("a, p, li, code { overflow-wrap: anywhere; }", serve.CSS) self.assertIn("header nav { display: flex; gap: 16px;", serve.CSS) @@ -86,6 +90,13 @@ def test_css_has_mobile_overflow_guards(self): self.assertIn(".memory-grid { grid-template-columns: minmax(0, 1fr); }", serve.CSS) self.assertIn(".memory-actions code, .memory-next code { word-break: break-word; }", serve.CSS) + def test_css_has_explicit_black_dark_theme(self): + self.assertIn(':root[data-theme="dark"]', serve.CSS) + self.assertIn("--bg: #000000;", serve.CSS) + self.assertIn("body { font-family: Georgia", serve.CSS) + self.assertIn("background: var(--bg); color: var(--text);", serve.CSS) + self.assertNotIn("background: #1a1a1a", serve.CSS) + def test_graph_labels_are_clamped_inside_canvas(self): wiki = self.make_wiki() write_page( diff --git a/wiki/_backlinks.json b/wiki/_backlinks.json index 16f1324..f239ff3 100644 --- a/wiki/_backlinks.json +++ b/wiki/_backlinks.json @@ -5,6 +5,7 @@ "machine-learning", "transformers", "gpt", + "index", "deep-learning-overview", "intro-to-ml" ], @@ -13,6 +14,7 @@ "machine-learning", "neural-networks", "gpt", + "index", "attention-is-all-you-need", "deep-learning-overview", "intro-to-ml" @@ -20,38 +22,44 @@ "gpt": [ "attention-mechanism", "transformers", + "index", "attention-is-all-you-need", "deep-learning-overview", "intro-to-ml" ], "attention-is-all-you-need": [ "attention-mechanism", - "transformers" + "transformers", + "index" ], "deep-learning-overview": [ "attention-mechanism", "machine-learning", "neural-networks", "transformers", - "gpt" + "gpt", + "index" ], "intro-to-ml": [ "machine-learning", "neural-networks", "transformers", - "gpt" + "gpt", + "index" ], "attention-mechanism": [ "machine-learning", "neural-networks", "transformers", "gpt", + "index", "attention-is-all-you-need" ], "machine-learning": [ "neural-networks", "transformers", "gpt", + "index", "deep-learning-overview", "intro-to-ml" ] @@ -95,6 +103,16 @@ "intro-to-ml", "neural-networks" ], + "index": [ + "attention-is-all-you-need", + "deep-learning-overview", + "intro-to-ml", + "attention-mechanism", + "machine-learning", + "neural-networks", + "transformers", + "gpt" + ], "attention-is-all-you-need": [ "transformers", "attention-mechanism", diff --git a/wiki/index.md b/wiki/index.md index 95b259b..f0668a0 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -1,13 +1,28 @@ # Link Wiki Index -> Last updated: 2026-04-15 | 0 pages | 0 sources +> Last updated: 2026-05-05 | 8 pages | 3 sources ## Categories -*No pages yet. Drop a source into `raw/` and ask your agent to ingest it.* +### Sources + +- [[attention-is-all-you-need]] — transformer architecture and self-attention. +- [[deep-learning-overview]] — deep learning foundations. +- [[intro-to-ml]] — machine learning fundamentals. + +### Concepts + +- [[attention-mechanism]] — learned weighting over input positions. +- [[machine-learning]] — systems that learn patterns from data. +- [[neural-networks]] — layered computational graphs for learning. +- [[transformers]] — attention-based sequence architecture. + +### Entities + +- [[gpt]] — decoder-only transformer language model family. ## Recent | Date | Operation | Pages Touched | |------|-----------|---------------| -| — | — | — | +| 2026-04-16 | ingest: sample AI/ML wiki | 8 pages | From cc92e2a347c0ffcf8d032b5862640ca1e02e3e25 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 19:24:36 -0600 Subject: [PATCH 033/292] Refresh README media asset paths Rename regenerated dark-mode screenshots and GIFs so README previews do not reuse cached old asset URLs. --- README.md | 10 +++++----- .../{link-demo-flow.gif => link-demo-flow-dark.gif} | Bin ...lain-memory.png => link-explain-memory-dark.png} | Bin docs/assets/{link-graph.png => link-graph-dark.png} | Bin docs/assets/{link-home.png => link-home-dark.png} | Bin ...dashboard.png => link-memory-dashboard-dark.png} | Bin 6 files changed, 5 insertions(+), 5 deletions(-) rename docs/assets/{link-demo-flow.gif => link-demo-flow-dark.gif} (100%) rename docs/assets/{link-explain-memory.png => link-explain-memory-dark.png} (100%) rename docs/assets/{link-graph.png => link-graph-dark.png} (100%) rename docs/assets/{link-home.png => link-home-dark.png} (100%) rename docs/assets/{link-memory-dashboard.png => link-memory-dashboard-dark.png} (100%) diff --git a/README.md b/README.md index 46b47ec..3e7c412 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ compound over time. Release notes: [CHANGELOG.md](CHANGELOG.md)

- Link demo flow: wiki, memory dashboard, graph, and memory explanation + Link demo flow: wiki, memory dashboard, graph, and memory explanation

## Why Link @@ -75,7 +75,7 @@ Browse pages by type, search locally, and open the same Markdown pages your agents use.

- Link wiki home + Link wiki home in dark mode

### Memory Dashboard @@ -83,7 +83,7 @@ agents use. See what agents can remember, what needs review, and what changed recently.

- Link Memory Dashboard + Link Memory Dashboard in dark mode

### Knowledge Graph @@ -92,7 +92,7 @@ Inspect relationships between source pages, concepts, entities, explorations, an memories. Dragging a node places it; double-clicking opens it.

- Link Knowledge Graph + Link Knowledge Graph in dark mode

### Explain Memory @@ -101,7 +101,7 @@ Every memory can explain why it exists, whether it is review-ready, and what source or log evidence supports it.

- Link Explain Memory view + Link Explain Memory view in dark mode

## First 10 Minutes diff --git a/docs/assets/link-demo-flow.gif b/docs/assets/link-demo-flow-dark.gif similarity index 100% rename from docs/assets/link-demo-flow.gif rename to docs/assets/link-demo-flow-dark.gif diff --git a/docs/assets/link-explain-memory.png b/docs/assets/link-explain-memory-dark.png similarity index 100% rename from docs/assets/link-explain-memory.png rename to docs/assets/link-explain-memory-dark.png diff --git a/docs/assets/link-graph.png b/docs/assets/link-graph-dark.png similarity index 100% rename from docs/assets/link-graph.png rename to docs/assets/link-graph-dark.png diff --git a/docs/assets/link-home.png b/docs/assets/link-home-dark.png similarity index 100% rename from docs/assets/link-home.png rename to docs/assets/link-home-dark.png diff --git a/docs/assets/link-memory-dashboard.png b/docs/assets/link-memory-dashboard-dark.png similarity index 100% rename from docs/assets/link-memory-dashboard.png rename to docs/assets/link-memory-dashboard-dark.png From 6cb68185be12cbc30a8523f5eb19e2b1e2e8cbe4 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 19:32:20 -0600 Subject: [PATCH 034/292] Add agent memory brief Add shared memory_brief logic, expose it through the CLI and MCP server, update agent-facing instructions, and cover the contract with core, CLI, and MCP tests. --- LINK.md | 3 +- README.md | 5 +- .../_shared/link-instructions-project.md | 2 + integrations/_shared/link-instructions.md | 2 + link.py | 61 ++++++++++++++++ mcp_package/README.md | 3 +- mcp_package/link_core/memory.py | 72 +++++++++++++++++++ mcp_package/link_mcp/server.py | 31 +++++++- tests/test_link_cli.py | 30 ++++++++ tests/test_mcp_contract.py | 11 +++ tests/test_memory_core.py | 6 ++ 11 files changed, 220 insertions(+), 6 deletions(-) diff --git a/LINK.md b/LINK.md index 769700f..4e13afb 100644 --- a/LINK.md +++ b/LINK.md @@ -286,6 +286,7 @@ Rules: - Keep memories specific and actionable. "User likes quality" is too vague; "User prefers release/* branches over codex/* branches" is useful. - Use `memory_type: preference` for user preferences, `decision` for choices made, `project` for project context, `fact` for stable facts, and `note` for everything else. - Use `scope: user` for broad personal preferences, `project` for the current project, and `global` for agent-wide principles. +- At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. - For long chat/session notes, run `python3 link.py propose-memories "" .` first. Treat proposals as candidates only; do not write them until the human confirms. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. @@ -337,7 +338,7 @@ When the human adds a new source to `raw/` and asks you to process it: When the human asks a question: -1. If the question may depend on user preferences, project decisions, or personal context, inspect `python3 link.py profile .` or MCP `memory_profile` when you need the memory overview, then run `python3 link.py recall "" .` or MCP `recall_memory` for focused recall. +1. If the question may depend on user preferences, project decisions, or personal context, start with `python3 link.py brief "" .` or MCP `memory_brief`. Use `profile`/`memory_profile` and `recall`/`recall_memory` afterward only when you need deeper detail. 2. **If `serve.py` is running:** call `GET /api/context?topic=` — returns the best matching page plus all related pages via graph traversal in one call. This is faster and uses fewer tokens than reading index.md manually. 3. **If server is not running:** read `wiki/index.md` to find relevant pages (check `also:` aliases for matches), then check `wiki/_backlinks.json` for pages that reference the topic. 4. Read the relevant pages and synthesize an answer. diff --git a/README.md b/README.md index 3e7c412..dd2bb13 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ should recall: ```bash python3 ~/link/link.py remember "I am testing Link as local personal memory for agents." ~/link --type preference --scope user --tags onboarding +python3 ~/link/link.py brief "local personal memory" ~/link python3 ~/link/link.py recall "local personal memory" ~/link python3 ~/link/link.py profile ~/link ``` @@ -341,6 +342,7 @@ Most agents should start with: | Tool | Use it when | |------|-------------| +| `memory_brief` | You are starting a session or task and need Link to prime the agent with relevant memory. | | `memory_profile` | You need to know what Link remembers about the user/project. | | `recall_memory` | You need preferences, decisions, facts, or project context. | | `get_context` | You need a topic plus its graph neighborhood. | @@ -349,7 +351,7 @@ Most agents should start with: | `remember_memory` | The user explicitly approves saving a durable memory. | | `propose_memories` | You want memory candidates from chat/session notes without writing. | -Full tool set: `memory_profile`, `memory_inbox`, `review_memory`, +Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, `propose_memories`, `update_memory`, `archive_memory`, `restore_memory`, `get_context`, `get_pages`, `get_backlinks`, `get_graph`, `rebuild_backlinks`. @@ -386,6 +388,7 @@ Common endpoints: | `python3 link.py ingest-status ` | Show pending raw files and graph index status. | | `python3 link.py remember "text" ` | Save a local agent memory; strong duplicates are refused unless `--allow-duplicate` is set. | | `python3 link.py propose-memories ` | Propose durable memories from notes without writing them. | +| `python3 link.py brief "task" ` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | | `python3 link.py recall "query" ` | Search local agent memories. | | `python3 link.py profile ` | Show what Link remembers by type, scope, status, and recency. | | `python3 link.py memory-inbox ` | Show memories that need review or stronger metadata. | diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index 5043ffc..a6f8069 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -2,6 +2,8 @@ This project has a Link wiki. Raw sources live in `raw/`, compiled wiki pages in `wiki/`, and direct memories in `wiki/memories/`. +When starting project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `python3 link.py brief "" .`. + When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `LINK.md` for instructions and follow the protocol. Otherwise, don't interfere — just be a normal assistant. diff --git a/integrations/_shared/link-instructions.md b/integrations/_shared/link-instructions.md index 63a137f..a3175aa 100644 --- a/integrations/_shared/link-instructions.md +++ b/integrations/_shared/link-instructions.md @@ -2,6 +2,8 @@ Local agent memory lives at `~/link/`. It has raw sources in `~/link/raw/`, compiled wiki pages in `~/link/wiki/`, and direct memories in `~/link/wiki/memories/`. +When starting personalized or project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `python3 ~/link/link.py brief "" ~/link`. + When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `~/link/LINK.md` for instructions and follow the protocol. Use terminal commands to access `~/link/` since it's outside the workspace. Otherwise, don't interfere — just be a normal assistant. diff --git a/link.py b/link.py index 828a022..2b9952b 100644 --- a/link.py +++ b/link.py @@ -8,6 +8,7 @@ python link.py remember "memory text" [target] python link.py propose-memories [target] python link.py update-memory "new memory text" [target] + python link.py brief ["task or question"] [target] python link.py recall "query" [target] python link.py profile [target] python link.py archive-memory [target] @@ -102,6 +103,7 @@ from link_core.memory import ( count_values as _core_count_values, mark_memory_reviewed as _core_mark_memory_reviewed, + memory_brief as _core_memory_brief, memory_explanation as _core_memory_explanation, memory_inbox as _core_memory_inbox, memory_profile as _core_memory_profile, @@ -756,6 +758,15 @@ def _memory_profile(wiki_dir: Path, limit: int = 10) -> dict[str, object]: return _core_memory_profile(_memory_records(wiki_dir), limit=limit, review_command="review-memory") +def _memory_brief(wiki_dir: Path, query: str = "", limit: int = 6) -> dict[str, object]: + return _core_memory_brief( + _memory_records(wiki_dir), + query=query, + limit=limit, + review_command="review-memory", + ) + + def _recall_memories( wiki_dir: Path, query: str, @@ -1818,6 +1829,48 @@ def _print_memory_list(title: str, records: list[dict[str, object]], empty: str print(f" {summary}") +def brief(target: Path, query: str = "", limit: int = 6, json_output: bool = False) -> int: + target = target.expanduser().resolve() + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + payload = _memory_brief(wiki_dir, query=query, limit=limit) + + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + + title = "Link memory brief" + if query: + title += f": {query}" + print(title) + print("") + profile_data = payload["profile"] + print( + f"{profile_data['active_count']} active memories · " + f"{payload['relevant_count']} relevant · " + f"{payload['review']['count']} need review" + ) + print(f"Types: {_format_counts(profile_data['by_type'])}") + print(f"Scopes: {_format_counts(profile_data['by_scope'])}") + print("") + + _print_memory_list("Relevant memories", payload["relevant_memories"]) + if payload["review"]["items"]: + print("") + print("Review queue") + for item in payload["review"]["items"][:3]: + print(f"- {item['title']} ({item['memory_type']} · {item['scope']})") + first_issue = item["issues"][0] + print(f" [{first_issue['severity']}] {first_issue['code']}: {first_issue['message']}") + print("") + print("Agent guidance") + for item in payload["agent_guidance"]: + print(f"- {item}") + return 0 + + def profile(target: Path, limit: int = 10, json_output: bool = False) -> int: target = target.expanduser().resolve() wiki_dir = _resolve_wiki_dir(target) @@ -2101,6 +2154,12 @@ def main(argv: list[str] | None = None) -> int: recall_cmd.add_argument("--include-archived", action="store_true", help="include archived and stale memories") recall_cmd.add_argument("--json", action="store_true", help="print machine-readable results") + brief_cmd = sub.add_parser("brief", help="prime an agent with relevant local memory") + brief_cmd.add_argument("query", nargs="?", default="", help="optional task or question to retrieve memory for") + brief_cmd.add_argument("target", nargs="?", default=".") + brief_cmd.add_argument("--limit", type=int, default=6) + brief_cmd.add_argument("--json", action="store_true", help="print machine-readable memory brief") + profile_cmd = sub.add_parser("profile", help="show what Link remembers") profile_cmd.add_argument("target", nargs="?", default=".") profile_cmd.add_argument("--limit", type=int, default=10) @@ -2185,6 +2244,8 @@ def main(argv: list[str] | None = None) -> int: json_output=args.json, include_archived=args.include_archived, ) + if args.command == "brief": + return brief(Path(args.target), query=args.query, limit=args.limit, json_output=args.json) if args.command == "profile": return profile(Path(args.target), limit=args.limit, json_output=args.json) if args.command == "archive-memory": diff --git a/mcp_package/README.md b/mcp_package/README.md index d427473..4a0d1ce 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -81,6 +81,7 @@ Custom wiki path: | Tool | Description | |------|-------------| +| `memory_brief(query?, limit?)` | Prime the agent before answering or coding with profile counts, relevant memories, review warnings, and safe memory rules. | | `memory_profile(limit?)` | Summarize what Link remembers by type, scope, status, recency, preferences, decisions, and project context. | | `memory_inbox(limit?, include_archived?)` | List memories that need user review, cleanup, or stronger metadata. | | `review_memory(identifier, note?)` | Mark a confirmed memory as reviewed. | @@ -98,7 +99,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review, `explain_memory` to audit why a memory exists, then `recall_memory` for user preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. +Start with `memory_brief`, passing the user's task as `query` when available. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index abd70f0..4916539 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -857,6 +857,78 @@ def typed(memory_type: str) -> list[dict[str, object]]: } +def memory_brief( + records: Iterable[Mapping[str, object]], + query: str = "", + limit: int = 6, + review_command: str = "review-memory", +) -> dict[str, object]: + """Return the compact memory payload an agent should read before work.""" + limit = max(1, min(limit, 20)) + q = query.strip() + record_list = [dict(record) for record in records] + profile = memory_profile(record_list, limit=limit, review_command=review_command) + inbox = memory_inbox(record_list, limit=limit, review_command=review_command) + + if q: + relevant = recall_memories(record_list, q, limit=limit) + selection = "query" + else: + relevant = [] + seen: set[str] = set() + for memory_type in ("preference", "decision", "project"): + for record in recent_memories(record_list): + name = str(record.get("name") or "") + if name in seen: + continue + if not is_active_memory(record): + continue + if str(record.get("memory_type") or "") != memory_type: + continue + relevant.append(slim_memory(record)) + seen.add(name) + if len(relevant) >= limit: + break + if len(relevant) >= limit: + break + if len(relevant) < limit: + for record in recent_memories(record_list): + name = str(record.get("name") or "") + if name in seen or not is_active_memory(record): + continue + relevant.append(slim_memory(record)) + seen.add(name) + if len(relevant) >= limit: + break + selection = "startup" + + guidance = [ + "Use relevant_memories as durable local context before answering or coding.", + "Call explain_memory before relying on a surprising, stale, or high-impact memory.", + "Only write memory after explicit user approval; use propose_memories for candidates first.", + "If a new memory duplicates an existing one, update the existing memory instead of creating another page.", + ] + if inbox["review_count"]: + guidance.insert( + 1, + "Some memories need review; treat them as provisional when they affect an important decision.", + ) + + return { + "query": q, + "selection": selection, + "profile": profile, + "relevant_count": len(relevant), + "relevant_memories": relevant, + "review": { + "count": inbox["review_count"], + "counts_by_severity": inbox["counts_by_severity"], + "items": inbox["items"], + }, + "agent_guidance": guidance, + } + + def score_memory(record: Mapping[str, object], query: str) -> int: q = query.lower().strip() tokens = [token for token in re.split(r"\W+", q) if len(token) >= 3] diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 3dde0a9..01a1c0b 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -52,9 +52,11 @@ mcp = FastMCP( "link", instructions=( - "Link is local personal memory for agents. Use memory_profile to inspect " - "what Link remembers, recall_memory for user preferences, decisions, and " - "project context, memory_inbox to find memories needing review, and " + "Link is local personal memory for agents. Start with memory_brief at " + "session start or before personalized/project work; pass the user's " + "task as the query when available. Use recall_memory for focused user " + "preferences, decisions, and project context, memory_profile to inspect " + "what Link remembers, memory_inbox to find memories needing review, and " "explain_memory to audit why a memory exists. Use propose_memories for " "chat or session notes before writing memory. Use search_wiki to find " "general pages and get_context to retrieve a topic with its full graph " @@ -73,6 +75,7 @@ from link_core.memory import ( count_values as _core_count_values, mark_memory_reviewed as _core_mark_memory_reviewed, + memory_brief as _core_memory_brief, memory_explanation as _core_memory_explanation, memory_inbox as _core_memory_inbox, memory_profile as _core_memory_profile, @@ -195,6 +198,15 @@ def _memory_profile(limit: int = 10) -> dict[str, object]: return _core_memory_profile(_memory_records(), limit=limit, review_command="review_memory") +def _memory_brief(query: str = "", limit: int = 6) -> dict[str, object]: + return _core_memory_brief( + _memory_records(), + query=_clean_text_input(query, max_len=500), + limit=limit, + review_command="review_memory", + ) + + def _recall_memories(query: str, limit: int = 10, include_archived: bool = False) -> list[dict[str, object]]: query = _clean_text_input(query) return _core_recall_memories( @@ -330,6 +342,19 @@ def rebuild_memory_backlinks() -> bool: # ── MCP Tools ───────────────────────────────────────────────────────── +@mcp.tool() +def memory_brief(query: str = "", limit: int = 6) -> str: + """Prime the agent with local memory before answering or coding. + + Call this at the start of a session or before a user task that may depend + on preferences, project decisions, or personal context. It returns profile + counts, relevant memories for the query, review warnings, and rules for + safe memory use. + """ + limit = _parse_limit(limit, default=6, max_limit=20) + return json.dumps(_memory_brief(query=query, limit=limit), ensure_ascii=False) + + @mcp.tool() def search_wiki(query: str, limit: int = 20) -> str: """Search the Link wiki by title, alias, tag, and full-text content. diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 02b0663..fc23f04 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -368,6 +368,36 @@ def test_profile_json(self): self.assertEqual(payload["preferences"][0]["name"], "prefer-local-personal-memory") self.assertEqual(payload["review_count"], 1) + def test_brief_primes_agent_memory(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + + out = StringIO() + with redirect_stdout(out): + code = link_cli.brief(target, "local personal memory") + + self.assertEqual(code, 0) + self.assertIn("Link memory brief: local personal memory", out.getvalue()) + self.assertIn("Prefer local personal memory", out.getvalue()) + self.assertIn("Agent guidance", out.getvalue()) + + def test_brief_json(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + + out = StringIO() + with redirect_stdout(out): + code = link_cli.brief(target, "local personal memory", json_output=True) + + payload = json.loads(out.getvalue()) + self.assertEqual(code, 0) + self.assertEqual(payload["selection"], "query") + self.assertEqual(payload["profile"]["memory_count"], 1) + self.assertEqual(payload["relevant_memories"][0]["name"], "prefer-local-personal-memory") + self.assertNotIn("body", payload["relevant_memories"][0]) + def test_memory_inbox_and_review_memory(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index e493659..63356cd 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -193,6 +193,17 @@ def test_memory_profile_contract(self): self.assertEqual(payload["recent"][0]["name"], "prefer-local-personal-memory") self.assertEqual(payload["preferences"][0]["memory_type"], "preference") + def test_memory_brief_contract(self): + payload = json.loads(self.server.memory_brief("local personal memory")) + + self.assertEqual(payload["selection"], "query") + self.assertEqual(payload["query"], "local personal memory") + self.assertEqual(payload["profile"]["memory_count"], 1) + self.assertEqual(payload["review"]["count"], 1) + self.assertEqual(payload["relevant_memories"][0]["name"], "prefer-local-personal-memory") + self.assertNotIn("body", payload["relevant_memories"][0]) + self.assertIn("agent_guidance", payload) + def test_memory_inbox_and_review_memory_contract(self): inbox = json.loads(self.server.memory_inbox()) reviewed = json.loads(self.server.review_memory( diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index a3aaa7c..91adb09 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -10,6 +10,7 @@ from link_core.memory import ( # noqa: E402 extract_wikilinks, mark_memory_reviewed, + memory_brief, memory_explanation, memory_inbox, memory_log_entries, @@ -67,6 +68,7 @@ def test_memory_records_profile_and_recall(self): records = memory_records(wiki) profile = memory_profile(records) + brief = memory_brief(records, query="release branches") inbox = memory_inbox(records) recalled = recall_memories(records, "release branches") @@ -75,6 +77,10 @@ def test_memory_records_profile_and_recall(self): self.assertEqual(profile["memory_count"], 2) self.assertEqual(profile["active_count"], 1) self.assertEqual(profile["archived"][0]["name"], "old-branch-rule") + self.assertEqual(brief["selection"], "query") + self.assertEqual(brief["relevant_memories"][0]["name"], "prefer-release-branches") + self.assertNotIn("body", brief["relevant_memories"][0]) + self.assertIn("agent_guidance", brief) self.assertEqual(inbox["review_count"], 0) self.assertEqual(recalled[0]["name"], "prefer-release-branches") self.assertNotIn("body", recalled[0]) From 0c3467ef5633fdee5479ae75047defdd1bc7320e Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 19:43:50 -0600 Subject: [PATCH 035/292] Add memory conflict detection Detect likely contradictions during memory creation, updates, and proposals; surface conflict candidates through CLI and MCP; document the resolution workflow and cover it with core, CLI, and MCP contract tests. --- CHANGELOG.md | 2 + LINK.md | 1 + README.md | 12 +- link.py | 49 ++++++ mcp_package/README.md | 6 +- mcp_package/link_core/memory.py | 264 +++++++++++++++++++++++++++++++- mcp_package/link_mcp/server.py | 24 ++- tests/test_link_cli.py | 28 ++++ tests/test_mcp_contract.py | 56 +++++++ tests/test_memory_core.py | 110 +++++++++++++ 10 files changed, 541 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc630c..a354c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added duplicate protection for `remember`/`remember_memory`; strong duplicate memories are refused unless explicitly allowed. - Added memory merge/update workflow with `update-memory` and MCP `update_memory`, including update counts, audit logs, backlink rebuilds, and review reset. - Added proposal-only memory extraction with `propose-memories` and MCP `propose_memories` for chat/session notes. +- Added agent memory briefs with `link.py brief` and MCP `memory_brief` so agents can prime themselves with relevant local memory before a task. +- Added conflict detection for memory writes, updates, and proposals; contradictory active memories are surfaced before saving unless explicitly allowed. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index 4e13afb..e487936 100644 --- a/LINK.md +++ b/LINK.md @@ -292,6 +292,7 @@ Rules: - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories that need human review. - If `remember` reports a duplicate candidate, inspect it with `python3 link.py explain-memory "" .` and merge new information with `python3 link.py update-memory "" "new detail" .` instead of creating another one. Use `--allow-duplicate` only when the human confirms it should be separate. +- If `remember`, `update-memory`, or `propose-memories` reports conflict candidates, stop and ask the human whether the older memory should be updated, archived, or allowed to coexist. Use `--allow-conflict` only when the human confirms both memories are true in different contexts. - After updating a memory, review it again with the human because `update-memory` resets `review_status` to `pending`. - After the human confirms a memory is accurate, run `python3 link.py review-memory "" .`. - Run `python3 link.py explain-memory "" .` when the human asks why an agent knows something or whether a memory is safe to use. diff --git a/README.md b/README.md index dd2bb13..b988264 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,10 @@ python3 ~/link/link.py update-memory prefer-feature-branches "Use focused branch python3 ~/link/link.py review-memory prefer-feature-branches ~/link --note "confirmed" ``` +If a new memory may contradict an active memory, Link reports conflict candidates +instead of saving silently. Update or archive the old memory, or use +`--allow-conflict` only when both memories should coexist. + Maintain the wiki: ```bash @@ -356,6 +360,10 @@ Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory` `propose_memories`, `update_memory`, `archive_memory`, `restore_memory`, `get_context`, `get_pages`, `get_backlinks`, `get_graph`, `rebuild_backlinks`. +Memory write tools return `duplicate_candidates` or `conflict_candidates` when +the safer next step is review, update, or archive instead of creating another +memory page. + ## HTTP API `serve.py` exposes Link locally while the web viewer is running. @@ -386,7 +394,7 @@ Common endpoints: |---------|-------------| | `python3 link.py demo` | Create `./link-demo` with a pre-ingested sample wiki. | | `python3 link.py ingest-status ` | Show pending raw files and graph index status. | -| `python3 link.py remember "text" ` | Save a local agent memory; strong duplicates are refused unless `--allow-duplicate` is set. | +| `python3 link.py remember "text" ` | Save a local agent memory; strong duplicates and likely conflicts are refused unless explicitly allowed. | | `python3 link.py propose-memories ` | Propose durable memories from notes without writing them. | | `python3 link.py brief "task" ` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | | `python3 link.py recall "query" ` | Search local agent memories. | @@ -394,7 +402,7 @@ Common endpoints: | `python3 link.py memory-inbox ` | Show memories that need review or stronger metadata. | | `python3 link.py review-memory ` | Mark a confirmed memory as reviewed. | | `python3 link.py explain-memory ` | Explain provenance, lifecycle, graph links, review issues, and recall readiness. | -| `python3 link.py update-memory "text" ` | Merge new text into an existing memory and reset review to pending. | +| `python3 link.py update-memory "text" ` | Merge new text into an existing memory, blocking likely conflicts with other active memories by default. | | `python3 link.py archive-memory ` | Reversibly hide a stale or wrong memory from default recall. | | `python3 link.py restore-memory ` | Restore an archived memory to active recall. | | `python3 link.py doctor ` | Check structure, graph health, source hygiene, and secret-looking content. | diff --git a/link.py b/link.py index 2b9952b..9acfe52 100644 --- a/link.py +++ b/link.py @@ -872,6 +872,7 @@ def _update_memory_page( text: str, source: str = "manual", timestamp: str | None = None, + allow_conflict: bool = False, ) -> dict[str, object]: target = target.expanduser().resolve() wiki_dir = _resolve_wiki_dir(target) @@ -894,6 +895,7 @@ def rebuild_memory_backlinks() -> bool: timestamp=timestamp or _utc_timestamp(), records=_memory_records(wiki_dir), review_command="review-memory", + allow_conflict=allow_conflict, log_writer=lambda ts, operation, description, lines: _append_log( wiki_dir, ts, @@ -915,6 +917,7 @@ def _write_memory_page( source: str = "manual", timestamp: str | None = None, allow_duplicate: bool = False, + allow_conflict: bool = False, ) -> dict[str, object]: target = target.expanduser().resolve() wiki_dir = _resolve_wiki_dir(target) @@ -940,6 +943,7 @@ def rebuild_memory_backlinks() -> bool: timestamp=timestamp or _utc_timestamp(), records=_memory_records(wiki_dir), allow_duplicate=allow_duplicate, + allow_conflict=allow_conflict, log_writer=lambda ts, operation, description, lines: _append_log( wiki_dir, ts, @@ -1446,6 +1450,7 @@ def remember( tags: str | None = None, source: str = "manual", allow_duplicate: bool = False, + allow_conflict: bool = False, json_output: bool = False, ) -> int: if not text or not text.strip(): @@ -1461,6 +1466,7 @@ def remember( tags=tags, source=source, allow_duplicate=allow_duplicate, + allow_conflict=allow_conflict, ) except (FileNotFoundError, ValueError) as exc: print(f"Could not remember: {exc}", file=sys.stderr) @@ -1471,6 +1477,25 @@ def remember( return 0 if not result.get("created"): + if result.get("conflict"): + print("Possible conflicting memory found") + print(f"Title requested: {result['title']}") + print(f"Type: {result['memory_type']}") + print(f"Scope: {result['scope']}") + print("") + print("Conflict candidates:") + for candidate in result.get("conflict_candidates", []): + reasons = ", ".join(candidate.get("conflict_reasons", [])) + print(f"- {candidate['title']} ({candidate['path']})") + if reasons: + print(f" Reasons: {reasons}") + print("") + print("Next:") + first = next(iter(result.get("conflict_candidates", [])), None) + if first: + print(f" python3 link.py explain-memory \"{first['name']}\" .") + print(" Update/archive the old memory, or use --allow-conflict only if both should coexist.") + return 0 print("Similar memory already exists") print(f"Title requested: {result['title']}") print(f"Type: {result['memory_type']}") @@ -1566,6 +1591,7 @@ def update_memory( identifier: str, text: str, source: str = "manual", + allow_conflict: bool = False, json_output: bool = False, ) -> int: if not text or not text.strip(): @@ -1577,6 +1603,7 @@ def update_memory( identifier, text, source=source, + allow_conflict=allow_conflict, ) except (FileNotFoundError, ValueError) as exc: print(f"Could not update memory: {exc}", file=sys.stderr) @@ -1586,6 +1613,24 @@ def update_memory( print(json.dumps(result, indent=2)) return 0 + if not result.get("updated") and result.get("conflict"): + print("Possible conflicting memory found") + print(f"Memory being updated: {result['title']} ({result['path']})") + print("") + print("Conflict candidates:") + for candidate in result.get("conflict_candidates", []): + reasons = ", ".join(candidate.get("conflict_reasons", [])) + print(f"- {candidate['title']} ({candidate['path']})") + if reasons: + print(f" Reasons: {reasons}") + print("") + print("Next:") + first = next(iter(result.get("conflict_candidates", [])), None) + if first: + print(f" python3 link.py explain-memory \"{first['name']}\" .") + print(" Update/archive the conflicting memory, or use --allow-conflict only if both should coexist.") + return 0 + print("Memory updated") print(f"Title: {result['title']}") print(f"Path: {result['path']}") @@ -2132,6 +2177,7 @@ def main(argv: list[str] | None = None) -> int: remember_cmd.add_argument("--tags", default=None, help="comma-separated tags") remember_cmd.add_argument("--source", default="manual", help="where this memory came from") remember_cmd.add_argument("--allow-duplicate", action="store_true", help="create a new memory even if a strong duplicate exists") + remember_cmd.add_argument("--allow-conflict", action="store_true", help="create a memory even if it may conflict with an active memory") remember_cmd.add_argument("--json", action="store_true", help="print machine-readable status") propose_cmd = sub.add_parser("propose-memories", help="propose durable memories from chat or session notes without writing them") @@ -2145,6 +2191,7 @@ def main(argv: list[str] | None = None) -> int: update_memory_cmd.add_argument("text", help="new memory text to merge") update_memory_cmd.add_argument("target", nargs="?", default=".") update_memory_cmd.add_argument("--source", default="manual", help="where this update came from") + update_memory_cmd.add_argument("--allow-conflict", action="store_true", help="update even if the text may conflict with another active memory") update_memory_cmd.add_argument("--json", action="store_true", help="print machine-readable status") recall_cmd = sub.add_parser("recall", help="search local agent memories") @@ -2219,6 +2266,7 @@ def main(argv: list[str] | None = None) -> int: tags=args.tags, source=args.source, allow_duplicate=args.allow_duplicate, + allow_conflict=args.allow_conflict, json_output=args.json, ) if args.command == "propose-memories": @@ -2234,6 +2282,7 @@ def main(argv: list[str] | None = None) -> int: args.identifier, args.text, source=args.source, + allow_conflict=args.allow_conflict, json_output=args.json, ) if args.command == "recall": diff --git a/mcp_package/README.md b/mcp_package/README.md index 4a0d1ce..6c3a8b6 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -87,9 +87,9 @@ Custom wiki path: | `review_memory(identifier, note?)` | Mark a confirmed memory as reviewed. | | `explain_memory(identifier)` | Explain provenance, lifecycle, graph links, review issues, and recall readiness for one memory. | | `recall_memory(query, limit?, include_archived?)` | Search durable local memories for preferences, decisions, and project context. | -| `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates require `allow_duplicate=true`. | +| `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. | | `propose_memories(text, source?, limit?)` | Propose durable memories from chat/session notes without writing them. | -| `update_memory(identifier, memory, source?)` | Merge new information into an existing memory, log it, rebuild backlinks, and reset review to pending. | +| `update_memory(identifier, memory, source?, allow_conflict?)` | Merge new information into an existing memory, blocking likely conflicts with other active memories by default. | | `archive_memory(identifier, reason?)` | Archive stale or wrong memory without deleting the Markdown page. | | `restore_memory(identifier)` | Restore archived memory to active status. | | `search_wiki(query, limit?)` | Ranked search — title (20pts), alias (8pts), tag (5pts), fulltext (2pts). Returns scores + snippets. | @@ -99,7 +99,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Start with `memory_brief`, passing the user's task as `query` when available. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. +Start with `memory_brief`, passing the user's task as `query` when available. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 4916539..1766735 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -25,6 +25,74 @@ MEMORY_SCOPES = ("user", "project", "global") MEMORY_REVIEW_STATUSES = ("pending", "reviewed", "needs_update") MEMORY_PROPOSAL_MIN_SCORE = 70 +MEMORY_CONFLICT_TYPES = {"preference", "decision", "project"} +MEMORY_STOPWORDS = { + "about", + "after", + "agent", + "agents", + "also", + "and", + "are", + "because", + "before", + "being", + "does", + "done", + "for", + "from", + "has", + "have", + "into", + "link", + "memory", + "more", + "not", + "now", + "our", + "prefer", + "prefers", + "project", + "should", + "that", + "the", + "their", + "this", + "use", + "user", + "users", + "want", + "wants", + "when", + "with", + "work", +} +NEGATION_TERMS = { + "avoid", + "disable", + "disabled", + "disallow", + "dont", + "don't", + "never", + "no", + "not", + "without", +} +CONFLICT_OPTION_GROUPS = { + "branch_policy": {"codex", "develop", "development", "direct", "feature", "main", "master", "release"}, + "storage_policy": {"cloud", "hosted", "local", "offline", "remote"}, + "theme": {"dark", "light", "system"}, + "install_method": {"brew", "global", "homebrew", "pipx", "system", "venv", "virtualenv"}, + "release_channel": {"github", "mcp", "pypi"}, +} +CONFLICT_GROUP_CONTEXT = { + "branch_policy": {"branch", "branches", "commit", "commits", "git", "merge", "pr", "pull", "push"}, + "storage_policy": {"agent", "agents", "backend", "data", "memory", "storage", "sync", "wiki"}, + "theme": {"background", "mode", "theme", "ui"}, + "install_method": {"install", "installer", "mcp", "package", "pip", "python", "setup"}, + "release_channel": {"package", "publish", "registry", "release", "version"}, +} MemoryLogWriter = Callable[[str, str, str, list[str]], None] BacklinkRebuilder = Callable[[], bool] @@ -60,6 +128,47 @@ def compact_memory_text(value: str) -> str: ) +def significant_memory_tokens(value: str) -> set[str]: + return { + token + for token in memory_tokens(value) + if token not in MEMORY_STOPWORDS + } + + +def has_negation(value: str) -> bool: + compact = compact_memory_text(value) + tokens = set(compact.split()) + if tokens & NEGATION_TERMS: + return True + return bool(re.search(r"\b(?:do not|does not|did not|should not|don't|can't|cannot)\b", value, re.IGNORECASE)) + + +def _extract_option_groups(value: str) -> dict[str, set[str]]: + tokens = memory_tokens(value) + groups: dict[str, set[str]] = {} + for group, options in CONFLICT_OPTION_GROUPS.items(): + matches = tokens & options + if matches: + groups[group] = matches + return groups + + +def _extract_preference_pairs(value: str) -> list[tuple[set[str], set[str]]]: + pairs: list[tuple[set[str], set[str]]] = [] + patterns = ( + r"\bprefer(?:s|red)?\s+(?P.+?)\s+over\s+(?P.+?)(?:[.;]|$)", + r"\buse\s+(?P.+?)\s+instead\s+of\s+(?P.+?)(?:[.;]|$)", + ) + for pattern in patterns: + for match in re.finditer(pattern, value, flags=re.IGNORECASE): + preferred = significant_memory_tokens(match.group("preferred")) + rejected = significant_memory_tokens(match.group("rejected")) + if preferred and rejected: + pairs.append((preferred, rejected)) + return pairs + + def slim_memory(record: Mapping[str, object]) -> dict[str, object]: return {key: value for key, value in record.items() if key != "body"} @@ -571,6 +680,7 @@ def update_memory_page( timestamp: str, records: Iterable[Mapping[str, object]] | None = None, review_command: str = "review-memory", + allow_conflict: bool = False, log_writer: MemoryLogWriter | None = None, rebuild_backlinks: BacklinkRebuilder | None = None, ) -> dict[str, object]: @@ -578,12 +688,31 @@ def update_memory_page( if not clean_text: raise ValueError("memory update text required") clean_source = source.strip() if source else "manual" - page_path, record, error = resolve_memory_page(wiki_dir, identifier, records=records) + record_list = [dict(item) for item in records] if records is not None else memory_records(wiki_dir) + page_path, record, error = resolve_memory_page(wiki_dir, identifier, records=record_list) if error: raise ValueError(error) assert page_path is not None and record is not None if not is_active_memory(record): raise ValueError("cannot update archived or stale memory; restore it first") + conflict_candidates = memory_conflict_candidates( + record_list, + clean_text, + str(record.get("title") or ""), + str(record.get("memory_type") or "note"), + str(record.get("scope") or "user"), + exclude_names=[str(record.get("name") or "")], + ) + if conflict_candidates and not allow_conflict: + return { + "updated": False, + "conflict": True, + "message": "This update may conflict with another active memory. Explain, update, or archive the conflicting memory first, or pass allow_conflict if both should coexist.", + "name": record["name"], + "path": record["path"], + "title": record["title"], + "conflict_candidates": conflict_candidates, + } previous_review_status = str(record.get("review_status") or "pending") previous_update_count = frontmatter_int(record.get("update_count")) @@ -630,6 +759,8 @@ def update_memory_page( "remaining_issue_count": len(issues), "remaining_issues": issues, "backlinks_rebuilt": bool(backlinks_rebuilt), + "conflict_override": bool(conflict_candidates and allow_conflict), + "conflict_candidates": conflict_candidates, } @@ -644,6 +775,7 @@ def write_memory_page( timestamp: str, records: Iterable[Mapping[str, object]] | None = None, allow_duplicate: bool = False, + allow_conflict: bool = False, log_writer: MemoryLogWriter | None = None, rebuild_backlinks: BacklinkRebuilder | None = None, ) -> dict[str, object]: @@ -678,6 +810,23 @@ def write_memory_page( "scope": scope, "candidates": duplicate_candidates, } + conflict_candidates = memory_conflict_candidates( + record_list, + clean_text, + title, + memory_type, + scope, + ) + if conflict_candidates and not allow_conflict: + return { + "created": False, + "conflict": True, + "message": "This memory may conflict with an active memory. Review or update the existing memory, archive stale memory, or pass allow_conflict if both should coexist.", + "title": memory_title_value, + "memory_type": memory_type, + "scope": scope, + "conflict_candidates": conflict_candidates, + } memories_dir = wiki_dir / "memories" memories_dir.mkdir(parents=True, exist_ok=True) @@ -742,6 +891,8 @@ def write_memory_page( "backlinks_rebuilt": bool(backlinks_rebuilt), "duplicate_override": bool(duplicate_candidates and allow_duplicate), "duplicate_candidates": duplicate_candidates, + "conflict_override": bool(conflict_candidates and allow_conflict), + "conflict_candidates": conflict_candidates, } @@ -1041,6 +1192,98 @@ def memory_duplicate_candidates( return [candidate for _, candidate in candidates[:limit]] +def memory_conflict_candidates( + records: Iterable[Mapping[str, object]], + text: str, + title: str | None, + memory_type: str, + scope: str, + limit: int = 3, + exclude_names: Iterable[str] | None = None, +) -> list[dict[str, object]]: + """Find active memories that may contradict the proposed memory.""" + if memory_type not in MEMORY_CONFLICT_TYPES: + return [] + + title_value = memory_title(text, title) + new_text = f"{title_value} {text}" + new_all_tokens = memory_tokens(new_text) + new_tokens = significant_memory_tokens(new_text) + new_negated = has_negation(new_text) + new_groups = _extract_option_groups(new_text) + new_pairs = _extract_preference_pairs(new_text) + excluded = {name for name in (exclude_names or []) if name} + candidates: list[tuple[int, dict[str, object]]] = [] + + for record in records: + name = str(record.get("name") or "") + if name in excluded or not is_active_memory(record): + continue + record_type = str(record.get("memory_type") or "") + record_scope = str(record.get("scope") or "") + if record_type != memory_type: + continue + if scope != record_scope and "global" not in {scope, record_scope}: + continue + + record_text = " ".join( + str(record.get(field) or "") + for field in ("title", "tldr", "snippet", "body") + ) + record_all_tokens = memory_tokens(record_text) + record_tokens = significant_memory_tokens(record_text) + overlap = sorted(new_tokens & record_tokens) + union = new_tokens | record_tokens + overlap_ratio = (len(overlap) / len(union)) if union else 0.0 + reasons: list[str] = [] + score = 0 + + if new_negated != has_negation(record_text) and len(overlap) >= 1 and overlap_ratio >= 0.45: + score = max(score, 92) + reasons.append("opposite_negation") + + record_groups = _extract_option_groups(record_text) + for group, new_options in new_groups.items(): + record_options = record_groups.get(group) + if not record_options: + continue + if new_options == record_options: + continue + # Ambiguous memories that mention multiple options without a clear + # preference are left for review instead of automatic conflict. + if len(new_options) > 1 or len(record_options) > 1: + continue + context = CONFLICT_GROUP_CONTEXT.get(group, set()) + context_matches = ( + not context + or ( + bool(new_all_tokens & context) + and bool(record_all_tokens & context) + ) + ) + if len(overlap) >= 2 or context_matches: + score = max(score, 88) + reasons.append(f"different_{group}") + + record_pairs = _extract_preference_pairs(record_text) + for new_preferred, new_rejected in new_pairs: + for record_preferred, record_rejected in record_pairs: + if (new_preferred & record_rejected) and (new_rejected & record_preferred): + score = max(score, 97) + reasons.append("reversed_preference") + + if score < 85: + continue + candidate = slim_memory(record) + candidate["conflict_score"] = min(score, 100) + candidate["conflict_reasons"] = sorted(set(reasons)) + candidate["matching_terms"] = overlap[:12] + candidates.append((int(candidate["conflict_score"]), candidate)) + + candidates.sort(key=lambda item: (-item[0], str(item[1]["title"]).lower())) + return [candidate for _, candidate in candidates[:limit]] + + def memory_proposal_segments(text: str) -> list[str]: text = re.sub(r"```.*?```", " ", text, flags=re.DOTALL) segments: list[str] = [] @@ -1180,6 +1423,7 @@ def propose_memories_from_text( limit: int = 10, writes_memory: bool = False, ) -> dict[str, object]: + record_list = [dict(record) for record in records] proposals: list[dict[str, object]] = [] seen: set[str] = set() skipped = 0 @@ -1202,12 +1446,25 @@ def propose_memories_from_text( scope = str(classified["scope"]) title = proposal_title(memory, memory_type) duplicate_candidates = memory_duplicate_candidates( - records, + record_list, + memory, + title, + memory_type, + scope, + ) + conflict_candidates = memory_conflict_candidates( + record_list, memory, title, memory_type, scope, ) + if duplicate_candidates: + suggested_action = "update-memory" + elif conflict_candidates: + suggested_action = "review-conflict" + else: + suggested_action = "remember" proposals.append({ "title": title, "memory": memory, @@ -1218,7 +1475,8 @@ def propose_memories_from_text( "reason": classified["reason"], "source": source, "duplicate_candidates": duplicate_candidates, - "suggested_action": "update-memory" if duplicate_candidates else "remember", + "conflict_candidates": conflict_candidates, + "suggested_action": suggested_action, }) if len(proposals) >= limit: break diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 01a1c0b..bf9c46c 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -63,6 +63,8 @@ "neighborhood. Only call remember_memory when the user explicitly asks " "you to remember something; if it returns duplicate candidates, use " "update_memory on the existing memory instead of forcing a duplicate. " + "If it returns conflict candidates, ask the user whether to update or " + "archive the older memory before forcing a conflict. " "Use archive_memory instead of deleting stale or wrong memories." ), ) @@ -277,7 +279,12 @@ def _mark_memory_reviewed(identifier: str, note: str = "") -> dict[str, object]: return result -def _update_memory_page(identifier: str, text: str, source: str = "mcp") -> dict[str, object]: +def _update_memory_page( + identifier: str, + text: str, + source: str = "mcp", + allow_conflict: bool = False, +) -> dict[str, object]: clean_text = _clean_text_input(text, max_len=4000) if not clean_text: raise ValueError("memory update text required") @@ -295,6 +302,7 @@ def rebuild_memory_backlinks() -> bool: timestamp=_utc_timestamp(), records=_memory_records(), review_command="review_memory", + allow_conflict=allow_conflict, log_writer=_append_log, rebuild_backlinks=rebuild_memory_backlinks, ) @@ -310,6 +318,7 @@ def _write_memory_page( tags: str = "", source: str = "mcp", allow_duplicate: bool = False, + allow_conflict: bool = False, ) -> dict[str, object]: clean_text = _clean_text_input(text, max_len=4000) if not clean_text: @@ -332,6 +341,7 @@ def rebuild_memory_backlinks() -> bool: timestamp=_utc_timestamp(), records=_memory_records(), allow_duplicate=allow_duplicate, + allow_conflict=allow_conflict, log_writer=_append_log, rebuild_backlinks=rebuild_memory_backlinks, ) @@ -469,7 +479,12 @@ def explain_memory(identifier: str) -> str: @mcp.tool() -def update_memory(identifier: str, memory: str, source: str = "mcp") -> str: +def update_memory( + identifier: str, + memory: str, + source: str = "mcp", + allow_conflict: bool = False, +) -> str: """Merge new information into an existing active memory. Use this when remember_memory returns a duplicate candidate or when the user @@ -477,7 +492,7 @@ def update_memory(identifier: str, memory: str, source: str = "mcp") -> str: the memory body, logged, and marked pending review. """ try: - result = _update_memory_page(identifier, memory, source=source) + result = _update_memory_page(identifier, memory, source=source, allow_conflict=allow_conflict) except ValueError as exc: return json.dumps({"updated": False, "error": str(exc)}) return json.dumps(result, ensure_ascii=False) @@ -517,12 +532,14 @@ def remember_memory( tags: str = "", source: str = "mcp", allow_duplicate: bool = False, + allow_conflict: bool = False, ) -> str: """Save a local agent memory as a Markdown page. Use only when the user explicitly asks you to remember something. The memory is written under wiki/memories/, indexed, logged, and kept local. Strong duplicates are refused unless allow_duplicate is true. + Potential conflicts are refused unless allow_conflict is true. memory_type: preference, decision, project, fact, or note. scope: user, project, or global. tags: optional comma-separated tags. @@ -536,6 +553,7 @@ def remember_memory( tags=tags, source=source, allow_duplicate=allow_duplicate, + allow_conflict=allow_conflict, ) except ValueError as exc: return json.dumps({"created": False, "error": str(exc)}) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index fc23f04..af97a69 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -217,6 +217,34 @@ def test_remember_blocks_strong_duplicate_by_default(self): self.assertTrue(override["duplicate_override"]) self.assertEqual(override["name"], "prefer-release-branches-2") + def test_remember_blocks_conflict_by_default(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + with redirect_stdout(StringIO()): + link_cli.remember( + target, + "User prefers release branches for Link work.", + title="Prefer release branches", + memory_type="preference", + scope="project", + ) + + conflict_out = StringIO() + with redirect_stdout(conflict_out): + conflict_code = link_cli.remember( + target, + "User prefers develop branches for Link work.", + title="Prefer develop branches", + memory_type="preference", + scope="project", + ) + + self.assertEqual(conflict_code, 0) + self.assertIn("Possible conflicting memory found", conflict_out.getvalue()) + self.assertIn("Prefer release branches", conflict_out.getvalue()) + self.assertFalse((target / "wiki/memories/prefer-develop-branches.md").exists()) + def test_update_memory_merges_text_and_resets_review(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 63356cd..8daa614 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -304,6 +304,28 @@ def test_remember_memory_blocks_strong_duplicate(self): self.assertTrue(override["duplicate_override"]) self.assertEqual(override["name"], "prefer-release-branches-2") + def test_remember_memory_blocks_conflict(self): + conflict = json.loads(self.server.remember_memory( + "User prefers cloud personal memory for agents.", + title="Prefer cloud personal memory", + memory_type="preference", + scope="user", + )) + override = json.loads(self.server.remember_memory( + "User prefers cloud personal memory for agents.", + title="Prefer cloud personal memory", + memory_type="preference", + scope="user", + allow_conflict=True, + )) + + self.assertFalse(conflict["created"]) + self.assertTrue(conflict["conflict"]) + self.assertEqual(conflict["conflict_candidates"][0]["name"], "prefer-local-personal-memory") + self.assertIn("different_storage_policy", conflict["conflict_candidates"][0]["conflict_reasons"]) + self.assertTrue(override["created"]) + self.assertTrue(override["conflict_override"]) + def test_update_memory_contract(self): reviewed = json.loads(self.server.review_memory("prefer-local-personal-memory", note="confirmed")) updated = json.loads(self.server.update_memory( @@ -328,6 +350,31 @@ def test_update_memory_contract(self): self.assertNotIn("reviewed_at:", memory_text) self.assertIn("update-memory", log_text) + def test_update_memory_blocks_conflict_with_other_memory(self): + created = json.loads(self.server.remember_memory( + "User prefers release branches for Link work.", + title="Prefer release branches", + memory_type="preference", + scope="project", + )) + other = json.loads(self.server.remember_memory( + "User prefers dark mode for Link work.", + title="Prefer dark mode", + memory_type="preference", + scope="project", + )) + conflict = json.loads(self.server.update_memory( + "prefer-dark-mode", + "User prefers develop branches for Link work.", + source="unit test", + )) + + self.assertTrue(created["created"]) + self.assertTrue(other["created"]) + self.assertFalse(conflict["updated"]) + self.assertTrue(conflict["conflict"]) + self.assertEqual(conflict["conflict_candidates"][0]["name"], "prefer-release-branches") + def test_propose_memories_contract(self): created = json.loads(self.server.remember_memory( "User prefers release branches for Link work.", @@ -354,6 +401,15 @@ def test_propose_memories_contract(self): self.assertEqual(payload["proposals"][1]["memory_type"], "decision") self.assertEqual(payload["proposals"][1]["suggested_action"], "remember") + def test_propose_memories_reports_conflicts(self): + payload = json.loads(self.server.propose_memories( + "I prefer cloud personal memory for agents.", + source="unit test session", + )) + + self.assertEqual(payload["proposals"][0]["suggested_action"], "review-conflict") + self.assertEqual(payload["proposals"][0]["conflict_candidates"][0]["name"], "prefer-local-personal-memory") + def test_rebuild_backlinks_contract(self): backlinks_path = self.target / "wiki/_backlinks.json" backlinks_path.write_text(json.dumps({"backlinks": {}, "forward": {}}), encoding="utf-8") diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 91adb09..791147e 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -11,6 +11,7 @@ extract_wikilinks, mark_memory_reviewed, memory_brief, + memory_conflict_candidates, memory_explanation, memory_inbox, memory_log_entries, @@ -120,6 +121,84 @@ def test_proposals_are_duplicate_aware_and_write_free(self): self.assertNotIn("body", duplicate) self.assertEqual(payload["proposals"][1]["memory_type"], "decision") + def test_memory_conflict_candidates_catch_branch_policy_changes(self): + records = [ + { + "name": "prefer-release-branches", + "path": "wiki/memories/prefer-release-branches.md", + "title": "Prefer release branches", + "memory_type": "preference", + "scope": "project", + "status": "active", + "tldr": "User prefers release branches for Link work.", + "snippet": "User prefers release branches for Link work.", + "body": "User prefers release branches for Link work.", + } + ] + + conflicts = memory_conflict_candidates( + records, + "User prefers develop branches for Link work.", + "Prefer develop branches", + "preference", + "project", + ) + + self.assertEqual(conflicts[0]["name"], "prefer-release-branches") + self.assertIn("different_branch_policy", conflicts[0]["conflict_reasons"]) + self.assertNotIn("body", conflicts[0]) + + def test_memory_conflict_candidates_avoid_release_word_false_positive(self): + records = [ + { + "name": "prefer-develop-branches", + "path": "wiki/memories/prefer-develop-branches.md", + "title": "Prefer develop branches", + "memory_type": "preference", + "scope": "project", + "status": "active", + "tldr": "User prefers develop branches for Link work.", + "snippet": "User prefers develop branches for Link work.", + "body": "User prefers develop branches for Link work.", + } + ] + + conflicts = memory_conflict_candidates( + records, + "User wants release notes to include screenshots.", + "Prefer release notes screenshots", + "preference", + "project", + ) + + self.assertEqual(conflicts, []) + + def test_memory_conflict_candidates_catch_negation(self): + records = [ + { + "name": "want-screenshots", + "path": "wiki/memories/want-screenshots.md", + "title": "Want screenshots", + "memory_type": "preference", + "scope": "user", + "status": "active", + "tldr": "User wants screenshots in release notes.", + "snippet": "User wants screenshots in release notes.", + "body": "User wants screenshots in release notes.", + } + ] + + conflicts = memory_conflict_candidates( + records, + "User does not want screenshots in release notes.", + "Avoid screenshots", + "preference", + "user", + ) + + self.assertEqual(conflicts[0]["name"], "want-screenshots") + self.assertIn("opposite_negation", conflicts[0]["conflict_reasons"]) + def test_memory_resolution_logs_and_recall_state(self): root = Path(tempfile.mkdtemp(prefix="link-memory-resolution-")) wiki = root / "wiki" @@ -439,6 +518,36 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str self.assertTrue(duplicate["duplicate"]) self.assertEqual(duplicate["candidates"][0]["name"], "prefer-release-branches") + conflict = write_memory_page( + wiki, + "User prefers develop branches for Link work.", + title="Prefer develop branches", + memory_type="preference", + scope="project", + tags="git, develop", + source="unit test", + timestamp="2026-05-05T07:30:00Z", + records=memory_records(wiki), + ) + self.assertFalse(conflict["created"]) + self.assertTrue(conflict["conflict"]) + self.assertEqual(conflict["conflict_candidates"][0]["name"], "prefer-release-branches") + + conflict_override = write_memory_page( + wiki, + "User prefers develop branches for Link work.", + title="Prefer develop branches", + memory_type="preference", + scope="project", + tags="git, develop", + source="unit test", + timestamp="2026-05-05T07:45:00Z", + records=memory_records(wiki), + allow_conflict=True, + ) + self.assertTrue(conflict_override["created"]) + self.assertTrue(conflict_override["conflict_override"]) + duplicate_override = write_memory_page( wiki, "User prefers release branches for Link work.", @@ -450,6 +559,7 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str timestamp="2026-05-05T08:00:00Z", records=memory_records(wiki), allow_duplicate=True, + allow_conflict=True, ) self.assertTrue(duplicate_override["created"]) self.assertTrue(duplicate_override["duplicate_override"]) From 92bf6bca14dca69b0482bca28b5976c8194835b1 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 19:53:09 -0600 Subject: [PATCH 036/292] Polish memory review workflow Add shared action plans to memory inbox and explanation payloads, surface primary actions through CLI/MCP/HTTP, reuse the shared action hints in the dashboard, and tighten review next-action copy. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 3 +- link.py | 13 ++- mcp_package/README.md | 4 +- mcp_package/link_core/memory.py | 168 ++++++++++++++++++++++++++++++++ serve.py | 57 ++++------- tests/test_demo_snapshot.py | 2 +- tests/test_link_cli.py | 8 ++ tests/test_mcp_contract.py | 2 + tests/test_memory_core.py | 51 ++++++++++ tests/test_serve.py | 2 +- 12 files changed, 268 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a354c74..569c942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added proposal-only memory extraction with `propose-memories` and MCP `propose_memories` for chat/session notes. - Added agent memory briefs with `link.py brief` and MCP `memory_brief` so agents can prime themselves with relevant local memory before a task. - Added conflict detection for memory writes, updates, and proposals; contradictory active memories are surfaced before saving unless explicitly allowed. +- Added shared memory review action plans so inbox and explanation payloads tell agents whether to review, update, archive, restore, or edit metadata next. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index e487936..ae6f784 100644 --- a/LINK.md +++ b/LINK.md @@ -290,7 +290,7 @@ Rules: - For long chat/session notes, run `python3 link.py propose-memories "" .` first. Treat proposals as candidates only; do not write them until the human confirms. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. -- Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories that need human review. +- Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. - If `remember` reports a duplicate candidate, inspect it with `python3 link.py explain-memory "" .` and merge new information with `python3 link.py update-memory "" "new detail" .` instead of creating another one. Use `--allow-duplicate` only when the human confirms it should be separate. - If `remember`, `update-memory`, or `propose-memories` reports conflict candidates, stop and ask the human whether the older memory should be updated, archived, or allowed to coexist. Use `--allow-conflict` only when the human confirms both memories are true in different contexts. - After updating a memory, review it again with the human because `update-memory` resets `review_status` to `pending`. diff --git a/README.md b/README.md index b988264..085d221 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ Most agents should start with: |------|-------------| | `memory_brief` | You are starting a session or task and need Link to prime the agent with relevant memory. | | `memory_profile` | You need to know what Link remembers about the user/project. | +| `memory_inbox` | You need review items with the safest next action for each memory. | | `recall_memory` | You need preferences, decisions, facts, or project context. | | `get_context` | You need a topic plus its graph neighborhood. | | `search_wiki` | You need ranked search across the wiki. | @@ -399,7 +400,7 @@ Common endpoints: | `python3 link.py brief "task" ` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | | `python3 link.py recall "query" ` | Search local agent memories. | | `python3 link.py profile ` | Show what Link remembers by type, scope, status, and recency. | -| `python3 link.py memory-inbox ` | Show memories that need review or stronger metadata. | +| `python3 link.py memory-inbox ` | Show memories that need review or stronger metadata with next-step commands. | | `python3 link.py review-memory ` | Mark a confirmed memory as reviewed. | | `python3 link.py explain-memory ` | Explain provenance, lifecycle, graph links, review issues, and recall readiness. | | `python3 link.py update-memory "text" ` | Merge new text into an existing memory, blocking likely conflicts with other active memories by default. | diff --git a/link.py b/link.py index 9acfe52..164e6d1 100644 --- a/link.py +++ b/link.py @@ -1769,7 +1769,18 @@ def memory_inbox( print(f" {item['path']}") for issue in item["issues"]: print(f" [{issue['severity']}] {issue['code']}: {issue['message']}") - print(f" Review: python3 link.py review-memory \"{item['name']}\" .") + primary = item.get("primary_action") or {} + if primary: + print(f" Next: {primary['label']} - {primary['description']}") + print(f" Command: {primary['command']}") + actions = [ + action + for action in item.get("actions", []) + if action.get("kind") != primary.get("kind") + ][:3] + if actions: + labels = ", ".join(str(action.get("label") or "") for action in actions) + print(f" Other actions: {labels}") return 0 diff --git a/mcp_package/README.md b/mcp_package/README.md index 6c3a8b6..f4697b5 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -83,7 +83,7 @@ Custom wiki path: |------|-------------| | `memory_brief(query?, limit?)` | Prime the agent before answering or coding with profile counts, relevant memories, review warnings, and safe memory rules. | | `memory_profile(limit?)` | Summarize what Link remembers by type, scope, status, recency, preferences, decisions, and project context. | -| `memory_inbox(limit?, include_archived?)` | List memories that need user review, cleanup, or stronger metadata. | +| `memory_inbox(limit?, include_archived?)` | List memories that need user review, cleanup, or stronger metadata with primary actions and tool-call hints. | | `review_memory(identifier, note?)` | Mark a confirmed memory as reviewed. | | `explain_memory(identifier)` | Explain provenance, lifecycle, graph links, review issues, and recall readiness for one memory. | | `recall_memory(query, limit?, include_archived?)` | Search durable local memories for preferences, decisions, and project context. | @@ -99,7 +99,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Start with `memory_brief`, passing the user's task as `query` when available. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. +Start with `memory_brief`, passing the user's task as `query` when available. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 1766735..9dc5808 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -311,6 +311,164 @@ def memory_review_issues( return issues +def _tool_name(command: str) -> str: + return command.replace("-", "_") + + +def _cli_command(command: str) -> str: + return command.replace("_", "-") + + +def _memory_action( + *, + kind: str, + label: str, + description: str, + command: str, + tool: str, + arguments: Mapping[str, object], + priority: str, +) -> dict[str, object]: + return { + "kind": kind, + "label": label, + "description": description, + "command": command, + "tool": tool, + "arguments": dict(arguments), + "priority": priority, + } + + +def memory_action_hints( + record: Mapping[str, object], + issues: Iterable[Mapping[str, str]] | None = None, + review_command: str = "review-memory", +) -> list[dict[str, object]]: + """Return ordered actions for resolving or auditing one memory.""" + name = str(record.get("name") or "") + path = str(record.get("path") or f"wiki/memories/{name}.md") + status = str(record.get("status") or "active").lower() + issue_list = [dict(issue) for issue in issues] if issues is not None else memory_review_issues(record, review_command) + issue_codes = {str(issue.get("code") or "") for issue in issue_list} + review_cli = _cli_command(review_command) + review_tool = _tool_name(review_command) + actions: list[dict[str, object]] = [] + seen: set[str] = set() + + def add(action: dict[str, object]) -> None: + kind = str(action["kind"]) + if kind in seen: + return + actions.append(action) + seen.add(kind) + + if status == "archived": + add(_memory_action( + kind="restore", + label="Restore", + description="Restore this archived memory to active recall if it is valid again.", + command=f'python3 link.py restore-memory "{name}" .', + tool="restore_memory", + arguments={"identifier": name}, + priority="high", + )) + add(_memory_action( + kind="explain", + label="Explain", + description="Inspect why this memory exists before restoring it.", + command=f'python3 link.py explain-memory "{name}" .', + tool="explain_memory", + arguments={"identifier": name}, + priority="medium", + )) + return actions + + if issue_codes & {"invalid_review_status", "invalid_memory_type", "invalid_scope", "missing_source", "missing_date_captured"}: + add(_memory_action( + kind="edit_metadata", + label="Edit metadata", + description="Fix the Markdown frontmatter, then run review again.", + command=f'$EDITOR "{path}"', + tool="edit_memory_file", + arguments={"path": path}, + priority="high", + )) + if issue_codes & {"needs_update", "missing_summary"}: + add(_memory_action( + kind="update", + label="Update", + description="Merge corrected memory text and reset review to pending.", + command=f'python3 link.py update-memory "{name}" "new detail" .', + tool="update_memory", + arguments={"identifier": name, "memory": "new detail"}, + priority="high", + )) + if "stale_status" in issue_codes: + add(_memory_action( + kind="archive", + label="Archive", + description="Archive this stale memory so default recall ignores it.", + command=f'python3 link.py archive-memory "{name}" . --reason "stale"', + tool="archive_memory", + arguments={"identifier": name, "reason": "stale"}, + priority="high", + )) + if "pending_review" in issue_codes and not any( + issue.get("severity") == "high" for issue in issue_list + ): + add(_memory_action( + kind="review", + label="Review", + description="Mark this memory reviewed after the user confirms it is accurate.", + command=f'python3 link.py {review_cli} "{name}" .', + tool=review_tool, + arguments={"identifier": name}, + priority="high", + )) + + add(_memory_action( + kind="explain", + label="Explain", + description="Audit provenance, graph links, lifecycle, and review state.", + command=f'python3 link.py explain-memory "{name}" .', + tool="explain_memory", + arguments={"identifier": name}, + priority="medium", + )) + if "update" not in seen: + add(_memory_action( + kind="update", + label="Update", + description="Merge a corrected detail into this memory.", + command=f'python3 link.py update-memory "{name}" "new detail" .', + tool="update_memory", + arguments={"identifier": name, "memory": "new detail"}, + priority="medium", + )) + if "archive" not in seen: + add(_memory_action( + kind="archive", + label="Archive", + description="Hide this memory from default recall without deleting the Markdown file.", + command=f'python3 link.py archive-memory "{name}" . --reason "why"', + tool="archive_memory", + arguments={"identifier": name, "reason": "why"}, + priority="medium", + )) + return actions + + +def primary_memory_action(actions: Iterable[Mapping[str, object]]) -> dict[str, object] | None: + action_list = [dict(action) for action in actions] + if not action_list: + return None + for action in action_list: + if str(action.get("priority") or "") == "high": + return action + return action_list[0] + + def memory_log_entries( wiki_dir: Path, record: Mapping[str, object], @@ -395,6 +553,7 @@ def memory_explanation( text = page_path.read_text(encoding="utf-8", errors="replace") _, body = parse_frontmatter(text) issues = memory_review_issues(record, review_command=review_command) + actions = memory_action_hints(record, issues=issues, review_command=review_command) backlinks, backlinks_error = load_backlinks_index(wiki_dir / "_backlinks.json") if backlinks_error: backlinks = build_backlinks(wiki_dir, body_only=backlinks_body_only) @@ -414,6 +573,8 @@ def memory_explanation( "review_note": record.get("review_note", ""), "issues": issues, "issue_count": len(issues), + "actions": actions, + "primary_action": primary_memory_action(actions), }, "provenance": { "source": record.get("source", ""), @@ -914,6 +1075,8 @@ def memory_inbox( item = slim_memory(record) item["issues"] = issues item["issue_count"] = len(issues) + item["actions"] = memory_action_hints(record, issues=issues, review_command=review_command) + item["primary_action"] = primary_memory_action(item["actions"]) item["highest_severity"] = min( (issue["severity"] for issue in issues), key=lambda severity: severity_rank.get(severity, 9), @@ -933,6 +1096,11 @@ def memory_inbox( "review_count": len(items), "counts_by_severity": counts_by_severity, "include_archived": include_archived, + "next_actions": [ + item["primary_action"] + for item in items[:limit] + if item.get("primary_action") + ], "items": items[:limit], } diff --git a/serve.py b/serve.py index f9eca7d..f011a80 100644 --- a/serve.py +++ b/serve.py @@ -12,6 +12,7 @@ from link_core.memory import ( count_values as _core_count_values, is_active_memory as _core_is_active_memory, + memory_action_hints as _core_memory_action_hints, memory_explanation as _core_memory_explanation, memory_inbox as _core_memory_inbox, memory_profile as _core_memory_profile, @@ -209,39 +210,18 @@ def _memory_activity_key(record: dict[str, object]) -> tuple[str, str, str]: def _memory_action_hints(record: dict[str, object]) -> list[dict[str, str]]: - name = str(record.get("name") or "") - status = str(record.get("status") or "active").lower() - hints = [ - { - "label": "Explain", - "href": f"/explain-memory?memory={urllib.parse.quote(name, safe='')}", - "command": f'python3 link.py explain-memory "{name}" .', - }, - ] - if status == "archived": - hints.append({ - "label": "Restore", - "href": "", - "command": f'python3 link.py restore-memory "{name}" .', - }) - return hints - hints.extend([ - { - "label": "Review", - "href": "", - "command": f'python3 link.py review-memory "{name}" .', - }, - { - "label": "Update", + hints: list[dict[str, str]] = [] + for action in _core_memory_action_hints(record, review_command="review-memory"): + item = { + "label": str(action.get("label") or ""), "href": "", - "command": f'python3 link.py update-memory "{name}" "new detail" .', - }, - { - "label": "Archive", - "href": "", - "command": f'python3 link.py archive-memory "{name}" . --reason "why"', - }, - ]) + "command": str(action.get("command") or ""), + "description": str(action.get("description") or ""), + } + if action.get("kind") == "explain": + name = str(record.get("name") or "") + item["href"] = f"/explain-memory?memory={urllib.parse.quote(name, safe='')}" + hints.append(item) return hints @@ -260,9 +240,10 @@ def _memory_dashboard_next_actions( actions: list[dict[str, str]] = [] if review_count: memory_label = "memory" if review_count == 1 else "memories" + verb = "needs" if review_count == 1 else "need" actions.append({ "label": "Review pending memories", - "detail": f"{review_count} {memory_label} need confirmation or metadata cleanup.", + "detail": f"{review_count} {memory_label} {verb} confirmation or metadata cleanup.", "href": "/inbox", "command": "python3 link.py memory-inbox .", "priority": "high", @@ -969,15 +950,15 @@ def _render_memory_card(record: dict[str, object], include_issues: bool = False) for issue in record["issues"] ) + "" actions = "" - for action in _memory_action_hints(record): - label = html.escape(action["label"]) - if action["href"]: - label_html = f'{label}' + for action in record.get("actions") or _memory_action_hints(record): + label = html.escape(str(action.get("label") or "")) + if action.get("href"): + label_html = f'{label}' else: label_html = label actions += ( f'
{label_html}' - f'{html.escape(action["command"])}
' + f'{html.escape(str(action.get("command") or ""))}' ) summary_html = f'

{html.escape(summary)}

' if summary else "" return ( diff --git a/tests/test_demo_snapshot.py b/tests/test_demo_snapshot.py index 54d1674..897c3fa 100644 --- a/tests/test_demo_snapshot.py +++ b/tests/test_demo_snapshot.py @@ -172,7 +172,7 @@ def test_demo_memory_dashboard_snapshot(self): self.assertEqual(dashboard["review_count"], 1) self.assertEqual(dashboard["next_actions"][0]["label"], "Review pending memories") self.assertEqual(dashboard["review"][0]["name"], "prefer-local-personal-memory") - self.assertEqual(dashboard["review"][0]["actions"][1]["label"], "Review") + self.assertEqual(dashboard["review"][0]["actions"][0]["label"], "Review") self.assertIn("Memory Dashboard", html) self.assertIn("Next actions", html) self.assertIn("Review needed", html) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index af97a69..78c8a33 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -440,6 +440,14 @@ def test_memory_inbox_and_review_memory(self): self.assertEqual(inbox["review_count"], 1) self.assertEqual(inbox["items"][0]["name"], "prefer-local-personal-memory") self.assertEqual(inbox["items"][0]["issues"][0]["code"], "pending_review") + self.assertEqual(inbox["items"][0]["primary_action"]["kind"], "review") + + text_out = StringIO() + with redirect_stdout(text_out): + text_code = link_cli.memory_inbox(target) + self.assertEqual(text_code, 0) + self.assertIn("Next: Review", text_out.getvalue()) + self.assertIn("Other actions:", text_out.getvalue()) review_out = StringIO() with redirect_stdout(review_out): diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 8daa614..69dedee 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -215,6 +215,8 @@ def test_memory_inbox_and_review_memory_contract(self): self.assertEqual(inbox["review_count"], 1) self.assertEqual(inbox["items"][0]["name"], "prefer-local-personal-memory") self.assertEqual(inbox["items"][0]["issues"][0]["code"], "pending_review") + self.assertEqual(inbox["items"][0]["primary_action"]["kind"], "review") + self.assertEqual(inbox["items"][0]["primary_action"]["tool"], "review_memory") self.assertTrue(reviewed["updated"]) self.assertEqual(reviewed["review_status"], "reviewed") self.assertEqual(reviewed["remaining_issue_count"], 0) diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 791147e..8ec26da 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -86,6 +86,56 @@ def test_memory_records_profile_and_recall(self): self.assertEqual(recalled[0]["name"], "prefer-release-branches") self.assertNotIn("body", recalled[0]) + def test_memory_inbox_returns_action_plan(self): + records = [ + { + "name": "needs-review", + "path": "wiki/memories/needs-review.md", + "title": "Needs review", + "memory_type": "preference", + "scope": "user", + "status": "active", + "date_captured": "2026-05-05T00:00:00Z", + "source": "unit test", + "review_status": "pending", + "tags": ["memory"], + "tldr": "User prefers reviewed memory.", + "snippet": "User prefers reviewed memory.", + } + ] + + inbox = memory_inbox(records) + item = inbox["items"][0] + + self.assertEqual(item["primary_action"]["kind"], "review") + self.assertEqual(item["primary_action"]["tool"], "review_memory") + self.assertIn("review-memory", item["primary_action"]["command"]) + self.assertEqual(inbox["next_actions"][0]["kind"], "review") + self.assertIn("actions", item) + + def test_memory_inbox_prioritizes_metadata_repairs(self): + records = [ + { + "name": "missing-source", + "path": "wiki/memories/missing-source.md", + "title": "Missing source", + "memory_type": "preference", + "scope": "user", + "status": "active", + "date_captured": "2026-05-05T00:00:00Z", + "source": "", + "review_status": "reviewed", + "tags": ["memory"], + "tldr": "User prefers metadata.", + "snippet": "User prefers metadata.", + } + ] + + inbox = memory_inbox(records) + + self.assertEqual(inbox["items"][0]["primary_action"]["kind"], "edit_metadata") + self.assertIn("wiki/memories/missing-source.md", inbox["items"][0]["primary_action"]["command"]) + def test_proposals_are_duplicate_aware_and_write_free(self): records = [ { @@ -342,6 +392,7 @@ def test_memory_explanation_reports_audit_payload_and_graph(self): self.assertEqual(explanation["graph"]["inbound"], ["agent-memory"]) self.assertEqual(explanation["graph"]["forward"], ["release-workflow"]) self.assertEqual(explanation["graph"]["wikilinks"], ["release-workflow"]) + self.assertEqual(explanation["review"]["primary_action"]["kind"], "explain") self.assertIn("User prefers focused commits", explanation["body"]) self.assertEqual(len(explanation["log_entries"]), 1) self.assertEqual(extract_wikilinks("[[one]] [[one]] [[two|Two]]"), ["one", "two"]) diff --git a/tests/test_serve.py b/tests/test_serve.py index 0e9ebfb..30338b2 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -236,7 +236,7 @@ def test_memory_dashboard_next_actions_uses_singular_memory_label(self): archived_count=0, ) - self.assertIn("1 memory need confirmation", actions[0]["detail"]) + self.assertIn("1 memory needs confirmation", actions[0]["detail"]) self.assertNotIn("memoryy", actions[0]["detail"]) def test_cache_invalidation_sees_existing_page_edits(self): From e7d35ab146839d498cb71544fd97a26ec28793a4 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:06:29 -0600 Subject: [PATCH 037/292] Add project-aware memory boundaries Add project keys for project-scoped memories, project-filtered recall/profile/brief/proposals, CLI/MCP project parameters, and tests to prevent cross-project memory bleed. --- CHANGELOG.md | 1 + LINK.md | 2 + README.md | 31 ++++--- .../_shared/link-instructions-project.md | 2 +- link.py | 91 +++++++++++++++++-- mcp_package/README.md | 14 +-- mcp_package/link_core/memory.py | 76 +++++++++++++++- mcp_package/link_mcp/server.py | 74 ++++++++++++--- tests/test_link_cli.py | 31 +++++++ tests/test_mcp_contract.py | 27 ++++++ tests/test_memory_core.py | 54 +++++++++++ 11 files changed, 356 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 569c942..b38b84f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added agent memory briefs with `link.py brief` and MCP `memory_brief` so agents can prime themselves with relevant local memory before a task. - Added conflict detection for memory writes, updates, and proposals; contradictory active memories are surfaced before saving unless explicitly allowed. - Added shared memory review action plans so inbox and explanation payloads tell agents whether to review, update, archive, restore, or edit metadata next. +- Added project-aware memory boundaries so project-scoped memories can carry a project key and recall/profile/brief keep other explicit projects out of context. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index ae6f784..d6e004d 100644 --- a/LINK.md +++ b/LINK.md @@ -170,6 +170,7 @@ type: memory title: "Short Memory Title" memory_type: preference | decision | project | fact | note scope: user | project | global +project: "optional-project-slug" status: active | stale | archived date_captured: "2026-04-09T14:30:00Z" updated_at: "" @@ -286,6 +287,7 @@ Rules: - Keep memories specific and actionable. "User likes quality" is too vague; "User prefers release/* branches over codex/* branches" is useful. - Use `memory_type: preference` for user preferences, `decision` for choices made, `project` for project context, `fact` for stable facts, and `note` for everything else. - Use `scope: user` for broad personal preferences, `project` for the current project, and `global` for agent-wide principles. +- For `scope: project`, include a project key when you know it. `link.py` infers this from repo-local installs; otherwise pass `--project ` or MCP `project`. - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. - For long chat/session notes, run `python3 link.py propose-memories "" .` first. Treat proposals as candidates only; do not write them until the human confirms. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. diff --git a/README.md b/README.md index 085d221..f89da59 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,9 @@ bash integrations/antigravity/install.sh # Google Antigravity This creates `~/link/`, installs or upgrades `link-mcp`, writes lightweight agent instructions, and leaves existing wiki data untouched on reinstall. -Use `--project` if you want memory scoped to the current repo instead of global -memory under `~/link`. +Use `--project` for a repo-local Link install. Project-scoped memories then get a +project key, and recall/profile/brief include global user memory plus that +project's memory. ### 2. Add One Source @@ -309,10 +310,10 @@ ingest raw/notes.md into Link Remember preferences and decisions directly: ```bash -python3 ~/link/link.py remember "User prefers feature branches for Link work." ~/link --title "Prefer feature branches" --type preference --scope project -python3 ~/link/link.py recall "branch preference" ~/link +python3 ~/link/link.py remember "User prefers feature branches for Link work." ~/link --title "Prefer feature branches" --type preference --scope project --project link +python3 ~/link/link.py recall "branch preference" ~/link --project link python3 ~/link/link.py explain-memory prefer-feature-branches ~/link -python3 ~/link/link.py update-memory prefer-feature-branches "Use focused branches for public PR work." ~/link +python3 ~/link/link.py update-memory prefer-feature-branches "Use focused branches for public PR work." ~/link --project link python3 ~/link/link.py review-memory prefer-feature-branches ~/link --note "confirmed" ``` @@ -320,6 +321,10 @@ If a new memory may contradict an active memory, Link reports conflict candidate instead of saving silently. Update or archive the old memory, or use `--allow-conflict` only when both memories should coexist. +For project installs, Link infers the project key from the repo directory. For a +global `~/link` wiki, pass `--project ` when saving or recalling repo +specific memories. + Maintain the wiki: ```bash @@ -365,6 +370,10 @@ Memory write tools return `duplicate_candidates` or `conflict_candidates` when the safer next step is review, update, or archive instead of creating another memory page. +Project-aware tools accept an optional `project` argument. When set, Link returns +broad user/global memory plus memories for that project, while keeping memories +from other explicit projects out of recall and duplicate/conflict checks. + ## HTTP API `serve.py` exposes Link locally while the web viewer is running. @@ -395,15 +404,15 @@ Common endpoints: |---------|-------------| | `python3 link.py demo` | Create `./link-demo` with a pre-ingested sample wiki. | | `python3 link.py ingest-status ` | Show pending raw files and graph index status. | -| `python3 link.py remember "text" ` | Save a local agent memory; strong duplicates and likely conflicts are refused unless explicitly allowed. | -| `python3 link.py propose-memories ` | Propose durable memories from notes without writing them. | -| `python3 link.py brief "task" ` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | -| `python3 link.py recall "query" ` | Search local agent memories. | -| `python3 link.py profile ` | Show what Link remembers by type, scope, status, and recency. | +| `python3 link.py remember "text" [--project slug]` | Save a local agent memory; strong duplicates and likely conflicts are refused unless explicitly allowed. | +| `python3 link.py propose-memories [--project slug]` | Propose durable memories from notes without writing them. | +| `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | +| `python3 link.py recall "query" [--project slug]` | Search local agent memories. | +| `python3 link.py profile [--project slug]` | Show what Link remembers by type, scope, status, and recency. | | `python3 link.py memory-inbox ` | Show memories that need review or stronger metadata with next-step commands. | | `python3 link.py review-memory ` | Mark a confirmed memory as reviewed. | | `python3 link.py explain-memory ` | Explain provenance, lifecycle, graph links, review issues, and recall readiness. | -| `python3 link.py update-memory "text" ` | Merge new text into an existing memory, blocking likely conflicts with other active memories by default. | +| `python3 link.py update-memory "text" [--project slug]` | Merge new text into an existing memory, blocking likely conflicts with other active memories by default. | | `python3 link.py archive-memory ` | Reversibly hide a stale or wrong memory from default recall. | | `python3 link.py restore-memory ` | Restore an archived memory to active recall. | | `python3 link.py doctor ` | Check structure, graph health, source hygiene, and secret-looking content. | diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index a6f8069..36a6dc4 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -2,7 +2,7 @@ This project has a Link wiki. Raw sources live in `raw/`, compiled wiki pages in `wiki/`, and direct memories in `wiki/memories/`. -When starting project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `python3 link.py brief "" .`. +When starting project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `python3 link.py brief "" .`. Project installs infer the current repo as the memory project key, so project-scoped memories stay separate from other repos while broad user memories still apply. When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `LINK.md` for instructions and follow the protocol. diff --git a/link.py b/link.py index 164e6d1..31dbb56 100644 --- a/link.py +++ b/link.py @@ -114,6 +114,7 @@ recent_memories as _core_recent_memories, resolve_memory_page as _core_resolve_memory_page, set_memory_status as _core_set_memory_status, + slugify as _core_slugify, top_tags as _core_top_tags, update_memory_page as _core_update_memory_page, write_memory_page as _core_write_memory_page, @@ -711,6 +712,15 @@ def _resolve_wiki_dir(target: Path) -> Path: return target / "wiki" +def _default_project(target: Path) -> str: + root = target.expanduser().resolve() + if root.name == "wiki": + root = root.parent + if (root / ".git").exists(): + return _core_slugify(root.name, fallback="") + return "" + + def _utc_timestamp() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -754,16 +764,22 @@ def _recent_memories(records: list[dict[str, object]]) -> list[dict[str, object] return _core_recent_memories(records) -def _memory_profile(wiki_dir: Path, limit: int = 10) -> dict[str, object]: - return _core_memory_profile(_memory_records(wiki_dir), limit=limit, review_command="review-memory") +def _memory_profile(wiki_dir: Path, limit: int = 10, project: str | None = None) -> dict[str, object]: + return _core_memory_profile( + _memory_records(wiki_dir), + limit=limit, + review_command="review-memory", + project=project, + ) -def _memory_brief(wiki_dir: Path, query: str = "", limit: int = 6) -> dict[str, object]: +def _memory_brief(wiki_dir: Path, query: str = "", limit: int = 6, project: str | None = None) -> dict[str, object]: return _core_memory_brief( _memory_records(wiki_dir), query=query, limit=limit, review_command="review-memory", + project=project, ) @@ -772,12 +788,14 @@ def _recall_memories( query: str, limit: int = 10, include_archived: bool = False, + project: str | None = None, ) -> list[dict[str, object]]: return _core_recall_memories( _memory_records(wiki_dir), query, limit=limit, include_archived=include_archived, + project=project, ) @@ -786,6 +804,7 @@ def _propose_memories_from_text( text: str, source: str = "inline", limit: int = 10, + project: str | None = None, ) -> dict[str, object]: return _core_propose_memories_from_text( text, @@ -793,6 +812,7 @@ def _propose_memories_from_text( source=source, limit=limit, writes_memory=False, + project=project, ) @@ -873,6 +893,7 @@ def _update_memory_page( source: str = "manual", timestamp: str | None = None, allow_conflict: bool = False, + project: str | None = None, ) -> dict[str, object]: target = target.expanduser().resolve() wiki_dir = _resolve_wiki_dir(target) @@ -896,6 +917,7 @@ def rebuild_memory_backlinks() -> bool: records=_memory_records(wiki_dir), review_command="review-memory", allow_conflict=allow_conflict, + project=project, log_writer=lambda ts, operation, description, lines: _append_log( wiki_dir, ts, @@ -918,6 +940,7 @@ def _write_memory_page( timestamp: str | None = None, allow_duplicate: bool = False, allow_conflict: bool = False, + project: str | None = None, ) -> dict[str, object]: target = target.expanduser().resolve() wiki_dir = _resolve_wiki_dir(target) @@ -941,6 +964,7 @@ def rebuild_memory_backlinks() -> bool: tags=tags, source=source, timestamp=timestamp or _utc_timestamp(), + project=project, records=_memory_records(wiki_dir), allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, @@ -1451,6 +1475,7 @@ def remember( source: str = "manual", allow_duplicate: bool = False, allow_conflict: bool = False, + project: str | None = None, json_output: bool = False, ) -> int: if not text or not text.strip(): @@ -1467,6 +1492,7 @@ def remember( source=source, allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, + project=project or _default_project(target), ) except (FileNotFoundError, ValueError) as exc: print(f"Could not remember: {exc}", file=sys.stderr) @@ -1517,6 +1543,8 @@ def remember( print(f"Path: {result['path']}") print(f"Type: {result['memory_type']}") print(f"Scope: {result['scope']}") + if result.get("project"): + print(f"Project: {result['project']}") print("") print("Next:") print(f" python3 link.py recall \"{result['title']}\" .") @@ -1543,6 +1571,7 @@ def propose_memories( target: Path, source_input: str, limit: int = 10, + project: str | None = None, json_output: bool = False, ) -> int: target = target.expanduser().resolve() @@ -1559,6 +1588,7 @@ def propose_memories( text, source=source, limit=max(1, min(limit, 20)), + project=project or _default_project(target), ) if json_output: @@ -1567,6 +1597,8 @@ def propose_memories( print("Memory proposals") print(f"Source: {result['source']}") + if result.get("project"): + print(f"Project: {result['project']}") print(f"Count: {result['count']}") if not result["proposals"]: print("No durable memory candidates found.") @@ -1575,6 +1607,8 @@ def propose_memories( print("") print(f"{index}. {proposal['title']} [{proposal['confidence']}]") print(f" Type: {proposal['memory_type']} | Scope: {proposal['scope']}") + if proposal.get("project"): + print(f" Project: {proposal['project']}") print(f" Action: {proposal['suggested_action']}") print(f" Memory: {proposal['memory']}") if proposal["duplicate_candidates"]: @@ -1592,6 +1626,7 @@ def update_memory( text: str, source: str = "manual", allow_conflict: bool = False, + project: str | None = None, json_output: bool = False, ) -> int: if not text or not text.strip(): @@ -1604,6 +1639,7 @@ def update_memory( text, source=source, allow_conflict=allow_conflict, + project=project or _default_project(target), ) except (FileNotFoundError, ValueError) as exc: print(f"Could not update memory: {exc}", file=sys.stderr) @@ -1649,24 +1685,35 @@ def recall( limit: int = 10, json_output: bool = False, include_archived: bool = False, + project: str | None = None, ) -> int: target = target.expanduser().resolve() wiki_dir = _resolve_wiki_dir(target) if not wiki_dir.exists(): print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) return 1 - results = _recall_memories(wiki_dir, query, limit=limit, include_archived=include_archived) + project_name = project or _default_project(target) + results = _recall_memories( + wiki_dir, + query, + limit=limit, + include_archived=include_archived, + project=project_name, + ) if json_output: print(json.dumps({ "query": query, "count": len(results), "include_archived": include_archived, + "project": project_name, "memories": results, }, indent=2)) return 0 print(f"Link memory recall: {query}") + if project_name: + print(f"Project: {project_name}") if include_archived: print("Including archived/stale memories") print("") @@ -1885,13 +1932,20 @@ def _print_memory_list(title: str, records: list[dict[str, object]], empty: str print(f" {summary}") -def brief(target: Path, query: str = "", limit: int = 6, json_output: bool = False) -> int: +def brief( + target: Path, + query: str = "", + limit: int = 6, + project: str | None = None, + json_output: bool = False, +) -> int: target = target.expanduser().resolve() wiki_dir = _resolve_wiki_dir(target) if not wiki_dir.exists(): print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) return 1 - payload = _memory_brief(wiki_dir, query=query, limit=limit) + project_name = project or _default_project(target) + payload = _memory_brief(wiki_dir, query=query, limit=limit, project=project_name) if json_output: print(json.dumps(payload, indent=2)) @@ -1901,6 +1955,8 @@ def brief(target: Path, query: str = "", limit: int = 6, json_output: bool = Fal if query: title += f": {query}" print(title) + if project_name: + print(f"Project: {project_name}") print("") profile_data = payload["profile"] print( @@ -1927,19 +1983,22 @@ def brief(target: Path, query: str = "", limit: int = 6, json_output: bool = Fal return 0 -def profile(target: Path, limit: int = 10, json_output: bool = False) -> int: +def profile(target: Path, limit: int = 10, project: str | None = None, json_output: bool = False) -> int: target = target.expanduser().resolve() wiki_dir = _resolve_wiki_dir(target) if not wiki_dir.exists(): print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) return 1 - profile_data = _memory_profile(wiki_dir, limit=limit) + project_name = project or _default_project(target) + profile_data = _memory_profile(wiki_dir, limit=limit, project=project_name) if json_output: print(json.dumps(profile_data, indent=2)) return 0 print(f"Link memory profile: {target}") + if project_name: + print(f"Project: {project_name}") print("") memory_count = profile_data["memory_count"] active_count = profile_data["active_count"] @@ -1947,6 +2006,8 @@ def profile(target: Path, limit: int = 10, json_output: bool = False) -> int: print(f"{memory_count} memor{'y' if memory_count == 1 else 'ies'} · {active_count} active · {review_count} need review") print(f"Types: {_format_counts(profile_data['by_type'])}") print(f"Scopes: {_format_counts(profile_data['by_scope'])}") + if profile_data["by_project"]: + print(f"Projects: {_format_counts(profile_data['by_project'])}") print(f"Status: {_format_counts(profile_data['by_status'])}") tags = ", ".join( f"{item['tag']} ({item['count']})" @@ -2187,6 +2248,7 @@ def main(argv: list[str] | None = None) -> int: remember_cmd.add_argument("--scope", choices=MEMORY_SCOPES, default="user") remember_cmd.add_argument("--tags", default=None, help="comma-separated tags") remember_cmd.add_argument("--source", default="manual", help="where this memory came from") + remember_cmd.add_argument("--project", default=None, help="project key for project-scoped memories") remember_cmd.add_argument("--allow-duplicate", action="store_true", help="create a new memory even if a strong duplicate exists") remember_cmd.add_argument("--allow-conflict", action="store_true", help="create a memory even if it may conflict with an active memory") remember_cmd.add_argument("--json", action="store_true", help="print machine-readable status") @@ -2195,6 +2257,7 @@ def main(argv: list[str] | None = None) -> int: propose_cmd.add_argument("source_input", help="text or path to a note/session file") propose_cmd.add_argument("target", nargs="?", default=".") propose_cmd.add_argument("--limit", type=int, default=10) + propose_cmd.add_argument("--project", default=None, help="project key for duplicate/conflict checks") propose_cmd.add_argument("--json", action="store_true", help="print machine-readable proposals") update_memory_cmd = sub.add_parser("update-memory", help="merge new text into an existing memory") @@ -2202,6 +2265,7 @@ def main(argv: list[str] | None = None) -> int: update_memory_cmd.add_argument("text", help="new memory text to merge") update_memory_cmd.add_argument("target", nargs="?", default=".") update_memory_cmd.add_argument("--source", default="manual", help="where this update came from") + update_memory_cmd.add_argument("--project", default=None, help="project key for conflict checks") update_memory_cmd.add_argument("--allow-conflict", action="store_true", help="update even if the text may conflict with another active memory") update_memory_cmd.add_argument("--json", action="store_true", help="print machine-readable status") @@ -2210,17 +2274,20 @@ def main(argv: list[str] | None = None) -> int: recall_cmd.add_argument("target", nargs="?", default=".") recall_cmd.add_argument("--limit", type=int, default=10) recall_cmd.add_argument("--include-archived", action="store_true", help="include archived and stale memories") + recall_cmd.add_argument("--project", default=None, help="include user/global memories plus this project's memories") recall_cmd.add_argument("--json", action="store_true", help="print machine-readable results") brief_cmd = sub.add_parser("brief", help="prime an agent with relevant local memory") brief_cmd.add_argument("query", nargs="?", default="", help="optional task or question to retrieve memory for") brief_cmd.add_argument("target", nargs="?", default=".") brief_cmd.add_argument("--limit", type=int, default=6) + brief_cmd.add_argument("--project", default=None, help="include user/global memories plus this project's memories") brief_cmd.add_argument("--json", action="store_true", help="print machine-readable memory brief") profile_cmd = sub.add_parser("profile", help="show what Link remembers") profile_cmd.add_argument("target", nargs="?", default=".") profile_cmd.add_argument("--limit", type=int, default=10) + profile_cmd.add_argument("--project", default=None, help="include user/global memories plus this project's memories") profile_cmd.add_argument("--json", action="store_true", help="print machine-readable profile") archive_cmd = sub.add_parser("archive-memory", help="archive a stale or unwanted memory") @@ -2276,6 +2343,7 @@ def main(argv: list[str] | None = None) -> int: scope=args.scope, tags=args.tags, source=args.source, + project=args.project, allow_duplicate=args.allow_duplicate, allow_conflict=args.allow_conflict, json_output=args.json, @@ -2285,6 +2353,7 @@ def main(argv: list[str] | None = None) -> int: Path(args.target), args.source_input, limit=args.limit, + project=args.project, json_output=args.json, ) if args.command == "update-memory": @@ -2294,6 +2363,7 @@ def main(argv: list[str] | None = None) -> int: args.text, source=args.source, allow_conflict=args.allow_conflict, + project=args.project, json_output=args.json, ) if args.command == "recall": @@ -2303,11 +2373,12 @@ def main(argv: list[str] | None = None) -> int: limit=args.limit, json_output=args.json, include_archived=args.include_archived, + project=args.project, ) if args.command == "brief": - return brief(Path(args.target), query=args.query, limit=args.limit, json_output=args.json) + return brief(Path(args.target), query=args.query, limit=args.limit, project=args.project, json_output=args.json) if args.command == "profile": - return profile(Path(args.target), limit=args.limit, json_output=args.json) + return profile(Path(args.target), limit=args.limit, project=args.project, json_output=args.json) if args.command == "archive-memory": return archive_memory(Path(args.target), args.identifier, reason=args.reason, json_output=args.json) if args.command == "restore-memory": diff --git a/mcp_package/README.md b/mcp_package/README.md index f4697b5..c02fea7 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -81,15 +81,15 @@ Custom wiki path: | Tool | Description | |------|-------------| -| `memory_brief(query?, limit?)` | Prime the agent before answering or coding with profile counts, relevant memories, review warnings, and safe memory rules. | -| `memory_profile(limit?)` | Summarize what Link remembers by type, scope, status, recency, preferences, decisions, and project context. | +| `memory_brief(query?, limit?, project?)` | Prime the agent before answering or coding with profile counts, relevant memories, review warnings, and safe memory rules. | +| `memory_profile(limit?, project?)` | Summarize what Link remembers by type, scope, status, recency, preferences, decisions, and project context. | | `memory_inbox(limit?, include_archived?)` | List memories that need user review, cleanup, or stronger metadata with primary actions and tool-call hints. | | `review_memory(identifier, note?)` | Mark a confirmed memory as reviewed. | | `explain_memory(identifier)` | Explain provenance, lifecycle, graph links, review issues, and recall readiness for one memory. | -| `recall_memory(query, limit?, include_archived?)` | Search durable local memories for preferences, decisions, and project context. | -| `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. | -| `propose_memories(text, source?, limit?)` | Propose durable memories from chat/session notes without writing them. | -| `update_memory(identifier, memory, source?, allow_conflict?)` | Merge new information into an existing memory, blocking likely conflicts with other active memories by default. | +| `recall_memory(query, limit?, include_archived?, project?)` | Search durable local memories for preferences, decisions, and project context. | +| `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?, project?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. | +| `propose_memories(text, source?, limit?, project?)` | Propose durable memories from chat/session notes without writing them. | +| `update_memory(identifier, memory, source?, allow_conflict?, project?)` | Merge new information into an existing memory, blocking likely conflicts with other active memories by default. | | `archive_memory(identifier, reason?)` | Archive stale or wrong memory without deleting the Markdown page. | | `restore_memory(identifier)` | Restore archived memory to active status. | | `search_wiki(query, limit?)` | Ranked search — title (20pts), alias (8pts), tag (5pts), fulltext (2pts). Returns scores + snippets. | @@ -99,7 +99,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Start with `memory_brief`, passing the user's task as `query` when available. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. +Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 9dc5808..f42dc55 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -102,6 +102,10 @@ def slugify(value: str, fallback: str = "memory") -> str: return slug or fallback +def normalize_project(value: str | None) -> str: + return slugify(value or "", fallback="") + + def memory_title(text: str, explicit_title: str | None = None) -> str: if explicit_title and explicit_title.strip(): return explicit_title.strip() @@ -177,6 +181,16 @@ def is_active_memory(record: Mapping[str, object]) -> bool: return str(record.get("status") or "active").lower() not in {"archived", "stale"} +def memory_visible_for_project(record: Mapping[str, object], project: str | None = None) -> bool: + project_name = normalize_project(project) + if not project_name: + return True + if str(record.get("scope") or "").lower() != "project": + return True + record_project = normalize_project(str(record.get("project") or "")) + return not record_project or record_project == project_name + + def extract_tldr(body: str) -> str: match = re.search(r">\s*\*\*TLDR:\*\*\s*(.+)", body) return match.group(1).strip() if match else "" @@ -212,6 +226,7 @@ def memory_records(wiki_dir: Path, include_body: bool = True) -> list[dict[str, "title": title, "memory_type": meta.get("memory_type") or "note", "scope": meta.get("scope") or "user", + "project": normalize_project(str(meta.get("project", ""))), "status": meta.get("status") or "active", "date_captured": meta.get("date_captured", ""), "updated_at": meta.get("updated_at", ""), @@ -842,6 +857,7 @@ def update_memory_page( records: Iterable[Mapping[str, object]] | None = None, review_command: str = "review-memory", allow_conflict: bool = False, + project: str | None = None, log_writer: MemoryLogWriter | None = None, rebuild_backlinks: BacklinkRebuilder | None = None, ) -> dict[str, object]: @@ -862,6 +878,7 @@ def update_memory_page( str(record.get("title") or ""), str(record.get("memory_type") or "note"), str(record.get("scope") or "user"), + project=project or str(record.get("project") or ""), exclude_names=[str(record.get("name") or "")], ) if conflict_candidates and not allow_conflict: @@ -872,6 +889,7 @@ def update_memory_page( "name": record["name"], "path": record["path"], "title": record["title"], + "project": record.get("project", ""), "conflict_candidates": conflict_candidates, } @@ -912,6 +930,7 @@ def update_memory_page( "name": updated_record["name"], "path": updated_record["path"], "title": updated_record["title"], + "project": updated_record.get("project", ""), "previous_review_status": previous_review_status, "review_status": updated_record.get("review_status", "pending"), "updated_at": timestamp, @@ -934,6 +953,7 @@ def write_memory_page( tags: str | None, source: str, timestamp: str, + project: str | None = None, records: Iterable[Mapping[str, object]] | None = None, allow_duplicate: bool = False, allow_conflict: bool = False, @@ -949,6 +969,7 @@ def write_memory_page( if not clean_text: raise ValueError("memory text required") clean_source = source.strip() if source is not None else "" + clean_project = normalize_project(project) if scope == "project" else "" memory_title_value = memory_title(clean_text, title) summary = clean_text.splitlines()[0].strip() if len(summary) > 180: @@ -960,6 +981,7 @@ def write_memory_page( title, memory_type, scope, + project=clean_project, ) if duplicate_candidates and not allow_duplicate: return { @@ -969,6 +991,7 @@ def write_memory_page( "title": memory_title_value, "memory_type": memory_type, "scope": scope, + "project": clean_project, "candidates": duplicate_candidates, } conflict_candidates = memory_conflict_candidates( @@ -977,6 +1000,7 @@ def write_memory_page( title, memory_type, scope, + project=clean_project, ) if conflict_candidates and not allow_conflict: return { @@ -986,6 +1010,7 @@ def write_memory_page( "title": memory_title_value, "memory_type": memory_type, "scope": scope, + "project": clean_project, "conflict_candidates": conflict_candidates, } @@ -998,13 +1023,14 @@ def write_memory_page( slug_tag = slugify(tag, fallback="") if slug_tag and slug_tag not in tag_values: tag_values.append(slug_tag) + project_line = f'project: "{frontmatter_string(clean_project)}"\n' if clean_project else "" page = f"""--- type: memory title: "{frontmatter_string(memory_title_value)}" memory_type: {memory_type} scope: {scope} -status: active +{project_line}status: active date_captured: "{timestamp}" source: "{frontmatter_string(clean_source)}" review_status: pending @@ -1049,6 +1075,7 @@ def write_memory_page( "title": memory_title_value, "memory_type": memory_type, "scope": scope, + "project": clean_project, "backlinks_rebuilt": bool(backlinks_rebuilt), "duplicate_override": bool(duplicate_candidates and allow_duplicate), "duplicate_candidates": duplicate_candidates, @@ -1143,9 +1170,15 @@ def memory_profile( records: Iterable[Mapping[str, object]], limit: int = 10, review_command: str = "review-memory", + project: str | None = None, ) -> dict[str, object]: limit = max(1, min(limit, 50)) - record_list = [dict(record) for record in records] + project_name = normalize_project(project) + record_list = [ + dict(record) + for record in records + if memory_visible_for_project(record, project_name) + ] active_records = [record for record in record_list if is_active_memory(record)] archived_records = [ record for record in record_list @@ -1164,8 +1197,18 @@ def typed(memory_type: str) -> list[dict[str, object]]: "memory_count": len(record_list), "active_count": len(active_records), "review_count": memory_inbox(record_list, limit=limit, review_command=review_command)["review_count"], + "project": project_name, "by_type": count_values(record_list, "memory_type"), "by_scope": count_values(record_list, "scope"), + "by_project": count_values( + [ + record + for record in record_list + if str(record.get("scope") or "") == "project" + and normalize_project(str(record.get("project") or "")) + ], + "project", + ), "by_status": count_values(record_list, "status"), "top_tags": top_tags(record_list), "recent": recent[:limit], @@ -1181,12 +1224,18 @@ def memory_brief( query: str = "", limit: int = 6, review_command: str = "review-memory", + project: str | None = None, ) -> dict[str, object]: """Return the compact memory payload an agent should read before work.""" limit = max(1, min(limit, 20)) q = query.strip() - record_list = [dict(record) for record in records] - profile = memory_profile(record_list, limit=limit, review_command=review_command) + project_name = normalize_project(project) + record_list = [ + dict(record) + for record in records + if memory_visible_for_project(record, project_name) + ] + profile = memory_profile(record_list, limit=limit, review_command=review_command, project=project_name) inbox = memory_inbox(record_list, limit=limit, review_command=review_command) if q: @@ -1235,6 +1284,7 @@ def memory_brief( return { "query": q, + "project": project_name, "selection": selection, "profile": profile, "relevant_count": len(relevant), @@ -1281,12 +1331,16 @@ def recall_memories( query: str, limit: int = 10, include_archived: bool = False, + project: str | None = None, ) -> list[dict[str, object]]: q = query.strip() if not q: return [] + project_name = normalize_project(project) scored: list[tuple[int, dict[str, object]]] = [] for record in records: + if not memory_visible_for_project(record, project_name): + continue if not include_archived and not is_active_memory(record): continue score = score_memory(record, q) @@ -1304,6 +1358,7 @@ def memory_duplicate_candidates( title: str | None, memory_type: str, scope: str, + project: str | None = None, limit: int = 3, ) -> list[dict[str, object]]: title_value = memory_title(text, title) @@ -1311,11 +1366,14 @@ def memory_duplicate_candidates( new_title = compact_memory_text(title_value) new_body = compact_memory_text(text) new_tokens = memory_tokens(f"{title_value} {text}") + project_name = normalize_project(project) candidates: list[tuple[int, dict[str, object]]] = [] for record in records: if not is_active_memory(record): continue + if scope == "project" and not memory_visible_for_project(record, project_name): + continue reasons: list[str] = [] score = 0 record_title = compact_memory_text(str(record.get("title") or "")) @@ -1366,6 +1424,7 @@ def memory_conflict_candidates( title: str | None, memory_type: str, scope: str, + project: str | None = None, limit: int = 3, exclude_names: Iterable[str] | None = None, ) -> list[dict[str, object]]: @@ -1380,6 +1439,7 @@ def memory_conflict_candidates( new_negated = has_negation(new_text) new_groups = _extract_option_groups(new_text) new_pairs = _extract_preference_pairs(new_text) + project_name = normalize_project(project) excluded = {name for name in (exclude_names or []) if name} candidates: list[tuple[int, dict[str, object]]] = [] @@ -1387,6 +1447,8 @@ def memory_conflict_candidates( name = str(record.get("name") or "") if name in excluded or not is_active_memory(record): continue + if scope == "project" and not memory_visible_for_project(record, project_name): + continue record_type = str(record.get("memory_type") or "") record_scope = str(record.get("scope") or "") if record_type != memory_type: @@ -1590,8 +1652,10 @@ def propose_memories_from_text( source: str = "inline", limit: int = 10, writes_memory: bool = False, + project: str | None = None, ) -> dict[str, object]: record_list = [dict(record) for record in records] + project_name = normalize_project(project) proposals: list[dict[str, object]] = [] seen: set[str] = set() skipped = 0 @@ -1619,6 +1683,7 @@ def propose_memories_from_text( title, memory_type, scope, + project=project_name, ) conflict_candidates = memory_conflict_candidates( record_list, @@ -1626,6 +1691,7 @@ def propose_memories_from_text( title, memory_type, scope, + project=project_name, ) if duplicate_candidates: suggested_action = "update-memory" @@ -1638,6 +1704,7 @@ def propose_memories_from_text( "memory": memory, "memory_type": memory_type, "scope": scope, + "project": project_name if scope == "project" else "", "confidence": confidence_label(score), "confidence_score": score, "reason": classified["reason"], @@ -1651,6 +1718,7 @@ def propose_memories_from_text( return { "proposed": True, "source": source, + "project": project_name, "count": len(proposals), "skipped_count": skipped, "proposals": proposals, diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index bf9c46c..10f1d15 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -89,6 +89,7 @@ resolve_memory_page as _core_resolve_memory_page, set_memory_status as _core_set_memory_status, slim_memory as _core_slim_memory, + slugify as _core_slugify, top_tags as _core_top_tags, update_memory_page as _core_update_memory_page, write_memory_page as _core_write_memory_page, @@ -122,6 +123,13 @@ def _parse_limit(value, default: int = 20, max_limit: int = 50) -> int: return min(max(limit, 1), max_limit) +def _default_project() -> str: + root = WIKI_DIR.parent + if (root / ".git").exists(): + return _core_slugify(root.name, fallback="") + return "" + + def _wiki_mtime() -> float: return _core_wiki_mtime(WIKI_DIR) @@ -196,36 +204,58 @@ def _recent_memories(records: list[dict[str, object]]) -> list[dict[str, object] return _core_recent_memories(records) -def _memory_profile(limit: int = 10) -> dict[str, object]: - return _core_memory_profile(_memory_records(), limit=limit, review_command="review_memory") +def _resolve_project(project: str = "") -> str: + return _clean_text_input(project) or _default_project() + + +def _memory_profile(limit: int = 10, project: str = "") -> dict[str, object]: + return _core_memory_profile( + _memory_records(), + limit=limit, + review_command="review_memory", + project=_resolve_project(project), + ) -def _memory_brief(query: str = "", limit: int = 6) -> dict[str, object]: +def _memory_brief(query: str = "", limit: int = 6, project: str = "") -> dict[str, object]: return _core_memory_brief( _memory_records(), query=_clean_text_input(query, max_len=500), limit=limit, review_command="review_memory", + project=_resolve_project(project), ) -def _recall_memories(query: str, limit: int = 10, include_archived: bool = False) -> list[dict[str, object]]: +def _recall_memories( + query: str, + limit: int = 10, + include_archived: bool = False, + project: str = "", +) -> list[dict[str, object]]: query = _clean_text_input(query) return _core_recall_memories( _memory_records(), query, limit=limit, include_archived=include_archived, + project=_resolve_project(project), ) -def _propose_memories_from_text(text: str, source: str = "mcp", limit: int = 10) -> dict[str, object]: +def _propose_memories_from_text( + text: str, + source: str = "mcp", + limit: int = 10, + project: str = "", +) -> dict[str, object]: return _core_propose_memories_from_text( text, _memory_records(), source=source, limit=limit, writes_memory=False, + project=_resolve_project(project), ) @@ -284,6 +314,7 @@ def _update_memory_page( text: str, source: str = "mcp", allow_conflict: bool = False, + project: str = "", ) -> dict[str, object]: clean_text = _clean_text_input(text, max_len=4000) if not clean_text: @@ -303,6 +334,7 @@ def rebuild_memory_backlinks() -> bool: records=_memory_records(), review_command="review_memory", allow_conflict=allow_conflict, + project=_resolve_project(project), log_writer=_append_log, rebuild_backlinks=rebuild_memory_backlinks, ) @@ -319,6 +351,7 @@ def _write_memory_page( source: str = "mcp", allow_duplicate: bool = False, allow_conflict: bool = False, + project: str = "", ) -> dict[str, object]: clean_text = _clean_text_input(text, max_len=4000) if not clean_text: @@ -339,6 +372,7 @@ def rebuild_memory_backlinks() -> bool: tags=_clean_text_input(tags, max_len=500), source=_clean_text_input(source, max_len=500), timestamp=_utc_timestamp(), + project=_resolve_project(project), records=_memory_records(), allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, @@ -353,7 +387,7 @@ def rebuild_memory_backlinks() -> bool: # ── MCP Tools ───────────────────────────────────────────────────────── @mcp.tool() -def memory_brief(query: str = "", limit: int = 6) -> str: +def memory_brief(query: str = "", limit: int = 6, project: str = "") -> str: """Prime the agent with local memory before answering or coding. Call this at the start of a session or before a user task that may depend @@ -362,7 +396,7 @@ def memory_brief(query: str = "", limit: int = 6) -> str: safe memory use. """ limit = _parse_limit(limit, default=6, max_limit=20) - return json.dumps(_memory_brief(query=query, limit=limit), ensure_ascii=False) + return json.dumps(_memory_brief(query=query, limit=limit, project=project), ensure_ascii=False) @mcp.tool() @@ -393,7 +427,7 @@ def search_wiki(query: str, limit: int = 20) -> str: @mcp.tool() -def recall_memory(query: str, limit: int = 10, include_archived: bool = False) -> str: +def recall_memory(query: str, limit: int = 10, include_archived: bool = False, project: str = "") -> str: """Search local agent memory pages first. Use this when the user asks about preferences, decisions, project context, @@ -405,17 +439,19 @@ def recall_memory(query: str, limit: int = 10, include_archived: bool = False) - limit = _parse_limit(limit, default=10) if not query: return json.dumps({"error": "query required", "query": "", "count": 0, "memories": []}) - memories = _recall_memories(query, limit=limit, include_archived=include_archived) + project_name = _resolve_project(project) + memories = _recall_memories(query, limit=limit, include_archived=include_archived, project=project_name) return json.dumps({ "query": query, "count": len(memories), "include_archived": include_archived, + "project": project_name, "memories": memories, }, ensure_ascii=False) @mcp.tool() -def propose_memories(text: str, source: str = "mcp", limit: int = 10) -> str: +def propose_memories(text: str, source: str = "mcp", limit: int = 10, project: str = "") -> str: """Propose durable memories from chat or session notes without writing them. Returns conservative memory proposals with type, scope, confidence, reason, @@ -427,11 +463,11 @@ def propose_memories(text: str, source: str = "mcp", limit: int = 10) -> str: return json.dumps({"proposed": False, "error": "text required", "count": 0, "proposals": []}) source = _clean_text_input(source, max_len=500) or "mcp" limit = _parse_limit(limit, default=10, max_limit=20) - return json.dumps(_propose_memories_from_text(clean_text, source=source, limit=limit), ensure_ascii=False) + return json.dumps(_propose_memories_from_text(clean_text, source=source, limit=limit, project=project), ensure_ascii=False) @mcp.tool() -def memory_profile(limit: int = 10) -> str: +def memory_profile(limit: int = 10, project: str = "") -> str: """Summarize what Link currently remembers. Use this to inspect the local memory profile before doing personalized work. @@ -439,7 +475,7 @@ def memory_profile(limit: int = 10) -> str: lists for preferences, decisions, and project context. """ limit = _parse_limit(limit, default=10) - return json.dumps(_memory_profile(limit=limit), ensure_ascii=False) + return json.dumps(_memory_profile(limit=limit, project=project), ensure_ascii=False) @mcp.tool() @@ -484,6 +520,7 @@ def update_memory( memory: str, source: str = "mcp", allow_conflict: bool = False, + project: str = "", ) -> str: """Merge new information into an existing active memory. @@ -492,7 +529,13 @@ def update_memory( the memory body, logged, and marked pending review. """ try: - result = _update_memory_page(identifier, memory, source=source, allow_conflict=allow_conflict) + result = _update_memory_page( + identifier, + memory, + source=source, + allow_conflict=allow_conflict, + project=project, + ) except ValueError as exc: return json.dumps({"updated": False, "error": str(exc)}) return json.dumps(result, ensure_ascii=False) @@ -533,6 +576,7 @@ def remember_memory( source: str = "mcp", allow_duplicate: bool = False, allow_conflict: bool = False, + project: str = "", ) -> str: """Save a local agent memory as a Markdown page. @@ -542,6 +586,7 @@ def remember_memory( Potential conflicts are refused unless allow_conflict is true. memory_type: preference, decision, project, fact, or note. scope: user, project, or global. + project: optional project key for project-scoped memories. tags: optional comma-separated tags. """ try: @@ -554,6 +599,7 @@ def remember_memory( source=source, allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, + project=project, ) except ValueError as exc: return json.dumps({"created": False, "error": str(exc)}) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 78c8a33..65e116c 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -355,6 +355,37 @@ def test_recall_json(self): self.assertEqual(payload["count"], 2) self.assertEqual(payload["memories"][0]["name"], "local-memory-preference") + def test_recall_json_filters_project(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + with redirect_stdout(StringIO()): + link_cli.remember( + target, + "Project uses alpha API for imports.", + title="Alpha API imports", + memory_type="project", + scope="project", + project="alpha", + ) + link_cli.remember( + target, + "Project uses beta API for imports.", + title="Beta API imports", + memory_type="project", + scope="project", + project="beta", + ) + + out = StringIO() + with redirect_stdout(out): + code = link_cli.recall(target, "API imports", project="alpha", json_output=True) + + payload = json.loads(out.getvalue()) + self.assertEqual(code, 0) + self.assertEqual(payload["project"], "alpha") + self.assertEqual([item["name"] for item in payload["memories"]], ["alpha-api-imports"]) + def test_profile_summarizes_memories(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 69dedee..eec8f82 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -182,6 +182,33 @@ def test_recall_memory_contract(self): self.assertEqual(payload["memories"][0]["name"], "prefer-local-personal-memory") self.assertEqual(payload["memories"][0]["memory_type"], "preference") + def test_recall_memory_project_filter_contract(self): + alpha = json.loads(self.server.remember_memory( + "Project uses alpha API for imports.", + title="Alpha API imports", + memory_type="project", + scope="project", + project="alpha", + )) + beta = json.loads(self.server.remember_memory( + "Project uses beta API for imports.", + title="Beta API imports", + memory_type="project", + scope="project", + project="beta", + )) + recalled = json.loads(self.server.recall_memory("API imports", project="alpha")) + profile = json.loads(self.server.memory_profile(project="alpha")) + + self.assertTrue(alpha["created"]) + self.assertTrue(beta["created"]) + self.assertEqual(alpha["project"], "alpha") + self.assertEqual(recalled["project"], "alpha") + self.assertEqual([memory["name"] for memory in recalled["memories"]], ["alpha-api-imports"]) + self.assertEqual(profile["project"], "alpha") + self.assertIn("alpha", profile["by_project"]) + self.assertNotIn("beta-api-imports", {memory["name"] for memory in profile["recent"]}) + def test_memory_profile_contract(self): payload = json.loads(self.server.memory_profile()) diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 8ec26da..892d209 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -39,6 +39,7 @@ def test_memory_records_profile_and_recall(self): "title: \"Prefer release branches\"\n" "memory_type: preference\n" "scope: project\n" + "project: \"Link Product\"\n" "status: active\n" "date_captured: \"2026-05-05T00:00:00Z\"\n" "source: \"unit test\"\n" @@ -77,6 +78,9 @@ def test_memory_records_profile_and_recall(self): self.assertIn("body", records[0]) self.assertEqual(profile["memory_count"], 2) self.assertEqual(profile["active_count"], 1) + release_memory = next(record for record in records if record["name"] == "prefer-release-branches") + self.assertEqual(release_memory["project"], "link-product") + self.assertEqual(profile["by_project"], {"link-product": 1}) self.assertEqual(profile["archived"][0]["name"], "old-branch-rule") self.assertEqual(brief["selection"], "query") self.assertEqual(brief["relevant_memories"][0]["name"], "prefer-release-branches") @@ -136,6 +140,56 @@ def test_memory_inbox_prioritizes_metadata_repairs(self): self.assertEqual(inbox["items"][0]["primary_action"]["kind"], "edit_metadata") self.assertIn("wiki/memories/missing-source.md", inbox["items"][0]["primary_action"]["command"]) + def test_recall_and_profile_filter_project_memories(self): + records = [ + { + "name": "global-style", + "path": "wiki/memories/global-style.md", + "title": "Global style", + "memory_type": "preference", + "scope": "user", + "project": "", + "status": "active", + "tldr": "User prefers concise status updates.", + "snippet": "User prefers concise status updates.", + "body": "User prefers concise status updates.", + }, + { + "name": "link-branching", + "path": "wiki/memories/link-branching.md", + "title": "Link branching", + "memory_type": "preference", + "scope": "project", + "project": "link", + "status": "active", + "tldr": "User prefers release branches for Link.", + "snippet": "User prefers release branches for Link.", + "body": "User prefers release branches for Link.", + }, + { + "name": "other-branching", + "path": "wiki/memories/other-branching.md", + "title": "Other branching", + "memory_type": "preference", + "scope": "project", + "project": "other", + "status": "active", + "tldr": "User prefers develop branches for Other.", + "snippet": "User prefers develop branches for Other.", + "body": "User prefers develop branches for Other.", + }, + ] + + recalled = recall_memories(records, "branches", project="link") + profile = memory_profile(records, project="link") + + self.assertEqual([record["name"] for record in recalled], ["link-branching"]) + self.assertEqual(profile["project"], "link") + self.assertEqual(profile["memory_count"], 2) + self.assertEqual(profile["by_scope"]["user"], 1) + self.assertEqual(profile["by_scope"]["project"], 1) + self.assertEqual(profile["by_project"]["link"], 1) + def test_proposals_are_duplicate_aware_and_write_free(self): records = [ { From a3faf5c68990915643fb7badb6a443af0457e586 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:08:46 -0600 Subject: [PATCH 038/292] Improve memory recall ranking Rank recall and memory briefs with transparent rank scores, preferring project-matched and reviewed memories while lowering archived or stale results when included. --- CHANGELOG.md | 1 + mcp_package/link_core/memory.py | 31 ++++++++++++++--- tests/test_memory_core.py | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b38b84f..7e61798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added conflict detection for memory writes, updates, and proposals; contradictory active memories are surfaced before saving unless explicitly allowed. - Added shared memory review action plans so inbox and explanation payloads tell agents whether to review, update, archive, restore, or edit metadata next. - Added project-aware memory boundaries so project-scoped memories can carry a project key and recall/profile/brief keep other explicit projects out of context. +- Improved memory recall ranking so project-matched and reviewed memories win ties while archived/stale memories rank lower when explicitly included. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index f42dc55..2623cb9 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -1239,7 +1239,7 @@ def memory_brief( inbox = memory_inbox(record_list, limit=limit, review_command=review_command) if q: - relevant = recall_memories(record_list, q, limit=limit) + relevant = recall_memories(record_list, q, limit=limit, project=project_name) selection = "query" else: relevant = [] @@ -1326,6 +1326,22 @@ def score_memory(record: Mapping[str, object], query: str) -> int: return score +def memory_rank_score(record: Mapping[str, object], match_score: int, project: str | None = None) -> int: + rank_score = match_score + project_name = normalize_project(project) + record_scope = str(record.get("scope") or "").lower() + record_project = normalize_project(str(record.get("project") or "")) + if project_name and record_scope == "project" and record_project == project_name: + rank_score += 6 + if str(record.get("review_status") or "").lower() == "reviewed": + rank_score += 3 + if str(record.get("review_status") or "").lower() == "needs_update": + rank_score -= 3 + if not is_active_memory(record): + rank_score -= 10 + return max(1, rank_score) + + def recall_memories( records: Iterable[Mapping[str, object]], query: str, @@ -1337,7 +1353,7 @@ def recall_memories( if not q: return [] project_name = normalize_project(project) - scored: list[tuple[int, dict[str, object]]] = [] + scored: list[tuple[int, int, str, dict[str, object]]] = [] for record in records: if not memory_visible_for_project(record, project_name): continue @@ -1345,11 +1361,16 @@ def recall_memories( continue score = score_memory(record, q) if score > 0: + rank_score = memory_rank_score(record, score, project=project_name) slim = slim_memory(record) slim["score"] = score - scored.append((score, slim)) - scored.sort(key=lambda item: (-item[0], str(item[1]["title"]).lower())) - return [record for _, record in scored[:limit]] + slim["rank_score"] = rank_score + recency = str(record.get("updated_at") or record.get("date_captured") or "") + scored.append((rank_score, score, recency, slim)) + scored.sort(key=lambda item: str(item[3]["title"]).lower()) + scored.sort(key=lambda item: item[2], reverse=True) + scored.sort(key=lambda item: (item[0], item[1]), reverse=True) + return [record for _, _, _, record in scored[:limit]] def memory_duplicate_candidates( diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 892d209..8f13afe 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -190,6 +190,66 @@ def test_recall_and_profile_filter_project_memories(self): self.assertEqual(profile["by_scope"]["project"], 1) self.assertEqual(profile["by_project"]["link"], 1) + def test_recall_ranking_prefers_reviewed_project_context(self): + records = [ + { + "name": "global-api-imports", + "path": "wiki/memories/global-api-imports.md", + "title": "API imports", + "memory_type": "project", + "scope": "user", + "project": "", + "status": "active", + "date_captured": "2026-05-03T00:00:00Z", + "review_status": "reviewed", + "tldr": "Use API imports.", + "snippet": "Use API imports.", + "body": "Use API imports.", + }, + { + "name": "alpha-api-imports-pending", + "path": "wiki/memories/alpha-api-imports-pending.md", + "title": "API imports", + "memory_type": "project", + "scope": "project", + "project": "alpha", + "status": "active", + "date_captured": "2026-05-02T00:00:00Z", + "review_status": "pending", + "tldr": "Use API imports.", + "snippet": "Use API imports.", + "body": "Use API imports.", + }, + { + "name": "alpha-api-imports-reviewed", + "path": "wiki/memories/alpha-api-imports-reviewed.md", + "title": "API imports", + "memory_type": "project", + "scope": "project", + "project": "alpha", + "status": "active", + "date_captured": "2026-05-01T00:00:00Z", + "review_status": "reviewed", + "tldr": "Use API imports.", + "snippet": "Use API imports.", + "body": "Use API imports.", + }, + ] + + recalled = recall_memories(records, "API imports", project="alpha") + brief = memory_brief(records, query="API imports", project="alpha") + + self.assertEqual( + [record["name"] for record in recalled], + [ + "alpha-api-imports-reviewed", + "alpha-api-imports-pending", + "global-api-imports", + ], + ) + self.assertGreater(recalled[0]["rank_score"], recalled[0]["score"]) + self.assertEqual(brief["relevant_memories"][0]["name"], "alpha-api-imports-reviewed") + def test_proposals_are_duplicate_aware_and_write_free(self): records = [ { From ac85498d15b12ccbe372d3e7e37b1d7471380522 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:12:04 -0600 Subject: [PATCH 039/292] Add proposal-only session capture Add link.py capture-session to preserve long session notes in raw/memory-captures, log the capture, and return memory proposals without writing durable memories. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 10 ++ .../_shared/link-instructions-project.md | 2 + link.py | 156 +++++++++++++++++- tests/test_link_cli.py | 32 ++++ 6 files changed, 199 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e61798..5e6d280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added shared memory review action plans so inbox and explanation payloads tell agents whether to review, update, archive, restore, or edit metadata next. - Added project-aware memory boundaries so project-scoped memories can carry a project key and recall/profile/brief keep other explicit projects out of context. - Improved memory recall ranking so project-matched and reviewed memories win ties while archived/stale memories rank lower when explicitly included. +- Added `link.py capture-session` to save long session notes under `raw/memory-captures/` and return proposal-only memory candidates for human approval. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index d6e004d..1d58c85 100644 --- a/LINK.md +++ b/LINK.md @@ -289,7 +289,7 @@ Rules: - Use `scope: user` for broad personal preferences, `project` for the current project, and `global` for agent-wide principles. - For `scope: project`, include a project key when you know it. `link.py` infers this from repo-local installs; otherwise pass `--project ` or MCP `project`. - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. -- For long chat/session notes, run `python3 link.py propose-memories "" .` first. Treat proposals as candidates only; do not write them until the human confirms. +- For long chat/session notes, prefer `python3 link.py capture-session "" .`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` instead. Do not write proposals until the human confirms. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. diff --git a/README.md b/README.md index f89da59..a3edd72 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,15 @@ For project installs, Link infers the project key from the repo directory. For a global `~/link` wiki, pass `--project ` when saving or recalling repo specific memories. +Capture longer session notes without silently writing memories: + +```bash +python3 ~/link/link.py capture-session session-notes.md ~/link --project link +``` + +This stores the note under `raw/memory-captures/`, logs the capture locally, and +returns memory proposals for human approval. + Maintain the wiki: ```bash @@ -406,6 +415,7 @@ Common endpoints: | `python3 link.py ingest-status ` | Show pending raw files and graph index status. | | `python3 link.py remember "text" [--project slug]` | Save a local agent memory; strong duplicates and likely conflicts are refused unless explicitly allowed. | | `python3 link.py propose-memories [--project slug]` | Propose durable memories from notes without writing them. | +| `python3 link.py capture-session [--project slug]` | Save chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates. | | `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | | `python3 link.py recall "query" [--project slug]` | Search local agent memories. | | `python3 link.py profile [--project slug]` | Show what Link remembers by type, scope, status, and recency. | diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index 36a6dc4..953e2a4 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -4,6 +4,8 @@ This project has a Link wiki. Raw sources live in `raw/`, compiled wiki pages in When starting project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `python3 link.py brief "" .`. Project installs infer the current repo as the memory project key, so project-scoped memories stay separate from other repos while broad user memories still apply. +For long session notes, use `python3 link.py capture-session "" .` to store a local raw capture and produce memory proposals without writing durable memories. + When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `LINK.md` for instructions and follow the protocol. Otherwise, don't interfere — just be a normal assistant. diff --git a/link.py b/link.py index 31dbb56..be9d0b9 100644 --- a/link.py +++ b/link.py @@ -120,6 +120,7 @@ write_memory_page as _core_write_memory_page, ) from link_core.frontmatter import ( + frontmatter_string as _frontmatter_string, parse_frontmatter as _parse_frontmatter, ) from link_core.wiki import ( @@ -712,10 +713,15 @@ def _resolve_wiki_dir(target: Path) -> Path: return target / "wiki" +def _resolve_link_root(target: Path) -> Path: + target = target.expanduser().resolve() + if target.name == "wiki" and (target / "index.md").exists(): + return target.parent + return target + + def _default_project(target: Path) -> str: - root = target.expanduser().resolve() - if root.name == "wiki": - root = root.parent + root = _resolve_link_root(target) if (root / ".git").exists(): return _core_slugify(root.name, fallback="") return "" @@ -1620,6 +1626,133 @@ def propose_memories( return 0 +def _capture_title(text: str, source: str, title: str | None = None) -> str: + if title and title.strip(): + return " ".join(title.split()) + if source != "inline": + stem = Path(source).stem.replace("-", " ").replace("_", " ").strip() + if stem: + return f"Memory capture: {stem.title()}" + first_line = next((line.strip() for line in text.splitlines() if line.strip()), "Session notes") + words = first_line.split() + short = " ".join(words[:10]).strip(" .") + return f"Memory capture: {short or 'Session notes'}" + + +def _capture_filename(timestamp: str, title: str, raw_dir: Path) -> Path: + safe_stamp = timestamp.replace("-", "").replace(":", "") + slug = _core_slugify(title.replace("Memory capture:", ""), fallback="session-notes") + base = f"{safe_stamp}-{slug}" + candidate = raw_dir / f"{base}.md" + counter = 2 + while candidate.exists(): + candidate = raw_dir / f"{base}-{counter}.md" + counter += 1 + return candidate + + +def capture_session( + target: Path, + source_input: str, + title: str | None = None, + limit: int = 10, + project: str | None = None, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + root = _resolve_link_root(target) + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + + text, source = _read_proposal_input(root, source_input) + if not text.strip(): + print("Session capture input is required", file=sys.stderr) + return 1 + + timestamp = _utc_timestamp() + project_name = project or _default_project(root) + capture_title = _capture_title(text, source, title) + capture_dir = root / "raw" / "memory-captures" + capture_dir.mkdir(parents=True, exist_ok=True) + capture_path = _capture_filename(timestamp, capture_title, capture_dir) + project_line = f'project: "{_frontmatter_string(project_name)}"\n' if project_name else "" + capture_path.write_text( + f"""--- +title: "{_frontmatter_string(capture_title)}" +source_type: conversation +date_captured: "{timestamp}" +{project_line}--- + +# {capture_title} + +Captured locally for Link memory review. This raw note is proposal-only until the user approves durable memories. + +## Source Input + +{source} + +## Notes + +{text.strip()} +""", + encoding="utf-8", + ) + rel_path = capture_path.relative_to(root).as_posix() + result = _propose_memories_from_text( + wiki_dir, + text, + source=rel_path, + limit=max(1, min(limit, 20)), + project=project_name, + ) + payload = { + "captured": True, + "path": rel_path, + "source_input": source, + "title": capture_title, + "project": project_name, + "proposals": result, + } + _append_log( + wiki_dir, + timestamp, + "capture-session", + f"Captured proposal-only session notes at {rel_path}", + [ + f"Source input: {source}", + f"Project: {project_name or 'none'}", + f"Proposals: {result['count']}", + ], + ) + + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + + print("Session captured") + print(f"Path: {rel_path}") + if project_name: + print(f"Project: {project_name}") + print(f"Proposals: {result['count']}") + if not result["proposals"]: + print("No durable memory candidates found.") + return 0 + for index, proposal in enumerate(result["proposals"], start=1): + print("") + print(f"{index}. {proposal['title']} [{proposal['confidence']}]") + print(f" Type: {proposal['memory_type']} | Scope: {proposal['scope']}") + if proposal.get("project"): + print(f" Project: {proposal['project']}") + print(f" Action: {proposal['suggested_action']}") + print(f" Memory: {proposal['memory']}") + print("") + print("Next:") + print(" Ask the user which proposals to remember, update, or discard.") + return 0 + + def update_memory( target: Path, identifier: str, @@ -2260,6 +2393,14 @@ def main(argv: list[str] | None = None) -> int: propose_cmd.add_argument("--project", default=None, help="project key for duplicate/conflict checks") propose_cmd.add_argument("--json", action="store_true", help="print machine-readable proposals") + capture_cmd = sub.add_parser("capture-session", help="save session notes to raw/ and propose memories") + capture_cmd.add_argument("source_input", help="text or path to a chat/session note") + capture_cmd.add_argument("target", nargs="?", default=".") + capture_cmd.add_argument("--title", default=None, help="title for the raw capture note") + capture_cmd.add_argument("--limit", type=int, default=10) + capture_cmd.add_argument("--project", default=None, help="project key for proposal checks") + capture_cmd.add_argument("--json", action="store_true", help="print machine-readable capture details") + update_memory_cmd = sub.add_parser("update-memory", help="merge new text into an existing memory") update_memory_cmd.add_argument("identifier", help="memory page name, title, or path") update_memory_cmd.add_argument("text", help="new memory text to merge") @@ -2356,6 +2497,15 @@ def main(argv: list[str] | None = None) -> int: project=args.project, json_output=args.json, ) + if args.command == "capture-session": + return capture_session( + Path(args.target), + args.source_input, + title=args.title, + limit=args.limit, + project=args.project, + json_output=args.json, + ) if args.command == "update-memory": return update_memory( Path(args.target), diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 65e116c..3636a70 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -457,6 +457,38 @@ def test_brief_json(self): self.assertEqual(payload["relevant_memories"][0]["name"], "prefer-local-personal-memory") self.assertNotIn("body", payload["relevant_memories"][0]) + def test_capture_session_writes_raw_note_and_proposes_only(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + before_memories = list((target / "wiki/memories").glob("*.md")) + + out = StringIO() + with redirect_stdout(out): + code = link_cli.capture_session( + target, + "Remember that the user prefers release branches for Link work.", + title="Release workflow session", + project="link", + json_output=True, + ) + + payload = json.loads(out.getvalue()) + capture_path = target / payload["path"] + after_memories = list((target / "wiki/memories").glob("*.md")) + capture_text = capture_path.read_text(encoding="utf-8") + log_text = (target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertEqual(code, 0) + self.assertTrue(payload["captured"]) + self.assertEqual(payload["project"], "link") + self.assertTrue(payload["path"].startswith("raw/memory-captures/")) + self.assertIn('project: "link"', capture_text) + self.assertIn("proposal-only", capture_text) + self.assertGreaterEqual(payload["proposals"]["count"], 1) + self.assertEqual(len(after_memories), len(before_memories)) + self.assertIn("capture-session", log_text) + def test_memory_inbox_and_review_memory(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" From e170c448656eafab0a962c42d61cd89362bedc05 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:15:10 -0600 Subject: [PATCH 040/292] Add MCP session capture Expose capture_session over MCP so agents can save long session notes under raw/memory-captures and return proposal-only memory candidates before writing durable memory. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 3 +- mcp_package/README.md | 3 +- mcp_package/link_mcp/server.py | 117 ++++++++++++++++++++++++++++++++- tests/test_mcp_contract.py | 22 +++++++ 6 files changed, 143 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6d280..8eed083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added project-aware memory boundaries so project-scoped memories can carry a project key and recall/profile/brief keep other explicit projects out of context. - Improved memory recall ranking so project-matched and reviewed memories win ties while archived/stale memories rank lower when explicitly included. - Added `link.py capture-session` to save long session notes under `raw/memory-captures/` and return proposal-only memory candidates for human approval. +- Added MCP `capture_session` so agents can preserve long session notes locally before asking which memory proposals to write. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index 1d58c85..2c2259a 100644 --- a/LINK.md +++ b/LINK.md @@ -289,7 +289,7 @@ Rules: - Use `scope: user` for broad personal preferences, `project` for the current project, and `global` for agent-wide principles. - For `scope: project`, include a project key when you know it. `link.py` infers this from repo-local installs; otherwise pass `--project ` or MCP `project`. - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. -- For long chat/session notes, prefer `python3 link.py capture-session "" .`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` instead. Do not write proposals until the human confirms. +- For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. diff --git a/README.md b/README.md index a3edd72..1bb84c2 100644 --- a/README.md +++ b/README.md @@ -369,10 +369,11 @@ Most agents should start with: | `explain_memory` | You need provenance and review state for a memory. | | `remember_memory` | The user explicitly approves saving a durable memory. | | `propose_memories` | You want memory candidates from chat/session notes without writing. | +| `capture_session` | You want to save long session notes locally before approving memory writes. | Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, -`propose_memories`, `update_memory`, `archive_memory`, `restore_memory`, +`propose_memories`, `capture_session`, `update_memory`, `archive_memory`, `restore_memory`, `get_context`, `get_pages`, `get_backlinks`, `get_graph`, `rebuild_backlinks`. Memory write tools return `duplicate_candidates` or `conflict_candidates` when diff --git a/mcp_package/README.md b/mcp_package/README.md index c02fea7..91128c8 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -89,6 +89,7 @@ Custom wiki path: | `recall_memory(query, limit?, include_archived?, project?)` | Search durable local memories for preferences, decisions, and project context. | | `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?, project?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. | | `propose_memories(text, source?, limit?, project?)` | Propose durable memories from chat/session notes without writing them. | +| `capture_session(text, title?, source?, limit?, project?)` | Save long chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates. | | `update_memory(identifier, memory, source?, allow_conflict?, project?)` | Merge new information into an existing memory, blocking likely conflicts with other active memories by default. | | `archive_memory(identifier, reason?)` | Archive stale or wrong memory without deleting the Markdown page. | | `restore_memory(identifier)` | Restore archived memory to active status. | @@ -99,7 +100,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `propose_memories` for long chat/session notes; it only returns candidates. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. +Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 10f1d15..99254cd 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -57,8 +57,9 @@ "task as the query when available. Use recall_memory for focused user " "preferences, decisions, and project context, memory_profile to inspect " "what Link remembers, memory_inbox to find memories needing review, and " - "explain_memory to audit why a memory exists. Use propose_memories for " - "chat or session notes before writing memory. Use search_wiki to find " + "explain_memory to audit why a memory exists. Use capture_session for " + "long chat or session notes that should be stored locally before memory " + "approval; use propose_memories when no raw capture is needed. Use search_wiki to find " "general pages and get_context to retrieve a topic with its full graph " "neighborhood. Only call remember_memory when the user explicitly asks " "you to remember something; if it returns duplicate candidates, use " @@ -73,6 +74,7 @@ _cache: dict = {} _cache_mtime: float = 0.0 MAX_TEXT_INPUT = 200 +MAX_CAPTURE_INPUT = 12000 from link_core.memory import ( count_values as _core_count_values, @@ -95,6 +97,7 @@ write_memory_page as _core_write_memory_page, ) from link_core.frontmatter import ( + frontmatter_string as _frontmatter_string, parse_frontmatter as _parse_frontmatter, ) from link_core.wiki import ( @@ -259,6 +262,96 @@ def _propose_memories_from_text( ) +def _capture_title(text: str, source: str, title: str = "") -> str: + if title.strip(): + return " ".join(title.split()) + if source.strip() and source.strip() != "mcp": + return f"Memory capture: {' '.join(source.strip().split())[:120]}" + first_line = next((line.strip() for line in text.splitlines() if line.strip()), "Session notes") + short = " ".join(first_line.split()[:10]).strip(" .") + return f"Memory capture: {short or 'Session notes'}" + + +def _capture_filename(timestamp: str, title: str, raw_dir: Path) -> Path: + safe_stamp = timestamp.replace("-", "").replace(":", "") + slug = _core_slugify(title.replace("Memory capture:", ""), fallback="session-notes") + base = f"{safe_stamp}-{slug}" + candidate = raw_dir / f"{base}.md" + counter = 2 + while candidate.exists(): + candidate = raw_dir / f"{base}-{counter}.md" + counter += 1 + return candidate + + +def _capture_session( + text: str, + title: str = "", + source: str = "mcp", + limit: int = 10, + project: str = "", +) -> dict[str, object]: + clean_text = _clean_text_input(text, max_len=MAX_CAPTURE_INPUT) + if not clean_text: + raise ValueError("session text required") + clean_source = _clean_text_input(source, max_len=500) or "mcp" + project_name = _resolve_project(project) + timestamp = _utc_timestamp() + capture_title = _capture_title(clean_text, clean_source, _clean_text_input(title, max_len=200)) + root = WIKI_DIR.parent + capture_dir = root / "raw" / "memory-captures" + capture_dir.mkdir(parents=True, exist_ok=True) + capture_path = _capture_filename(timestamp, capture_title, capture_dir) + project_line = f'project: "{_frontmatter_string(project_name)}"\n' if project_name else "" + capture_path.write_text( + f"""--- +title: "{_frontmatter_string(capture_title)}" +source_type: conversation +date_captured: "{timestamp}" +{project_line}--- + +# {capture_title} + +Captured locally for Link memory review. This raw note is proposal-only until the user approves durable memories. + +## Source Input + +{clean_source} + +## Notes + +{clean_text} +""", + encoding="utf-8", + ) + rel_path = capture_path.relative_to(root).as_posix() + proposals = _propose_memories_from_text( + clean_text, + source=rel_path, + limit=limit, + project=project_name, + ) + _append_log( + timestamp, + "capture-session", + f"Captured proposal-only session notes at {rel_path}", + [ + f"Source input: {clean_source}", + f"Project: {project_name or 'none'}", + f"Proposals: {proposals['count']}", + ], + ) + _cache.clear() + return { + "captured": True, + "path": rel_path, + "source": clean_source, + "title": capture_title, + "project": project_name, + "proposals": proposals, + } + + def _append_log(timestamp: str, operation: str, description: str, lines: list[str]) -> None: log_path = WIKI_DIR / "log.md" if not log_path.exists(): @@ -466,6 +559,26 @@ def propose_memories(text: str, source: str = "mcp", limit: int = 10, project: s return json.dumps(_propose_memories_from_text(clean_text, source=source, limit=limit, project=project), ensure_ascii=False) +@mcp.tool() +def capture_session(text: str, title: str = "", source: str = "mcp", limit: int = 10, project: str = "") -> str: + """Save long chat/session notes locally and return memory proposals only. + + Writes a raw note under raw/memory-captures/ and logs the capture, but does + not create durable memory pages. Use this when the user wants the session + preserved for review before approving remember_memory or update_memory. + """ + limit = _parse_limit(limit, default=10, max_limit=20) + try: + result = _capture_session(text, title=title, source=source, limit=limit, project=project) + except ValueError as exc: + return json.dumps({ + "captured": False, + "error": str(exc), + "proposals": {"proposed": False, "count": 0, "proposals": []}, + }) + return json.dumps(result, ensure_ascii=False) + + @mcp.tool() def memory_profile(limit: int = 10, project: str = "") -> str: """Summarize what Link currently remembers. diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index eec8f82..6572350 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -231,6 +231,28 @@ def test_memory_brief_contract(self): self.assertNotIn("body", payload["relevant_memories"][0]) self.assertIn("agent_guidance", payload) + def test_capture_session_contract(self): + before_memories = list((self.target / "wiki/memories").glob("*.md")) + + payload = json.loads(self.server.capture_session( + "Remember that the user prefers release branches for Link work.", + title="Release workflow session", + project="link", + )) + + capture_path = self.target / payload["path"] + after_memories = list((self.target / "wiki/memories").glob("*.md")) + capture_text = capture_path.read_text(encoding="utf-8") + log_text = (self.target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertTrue(payload["captured"]) + self.assertEqual(payload["project"], "link") + self.assertTrue(payload["path"].startswith("raw/memory-captures/")) + self.assertIn('project: "link"', capture_text) + self.assertGreaterEqual(payload["proposals"]["count"], 1) + self.assertEqual(len(after_memories), len(before_memories)) + self.assertIn("capture-session", log_text) + def test_memory_inbox_and_review_memory_contract(self): inbox = json.loads(self.server.memory_inbox()) reviewed = json.loads(self.server.review_memory( From 1b8ea614db47b86711a3f2a382c03eb5cd60ce43 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:17:33 -0600 Subject: [PATCH 041/292] Warn on secrets in session captures Add shared secret-looking value detection and surface non-blocking warnings in CLI and MCP capture-session results without logging secret values. --- CHANGELOG.md | 1 + README.md | 5 +++-- link.py | 26 +++++++++++--------------- mcp_package/README.md | 2 +- mcp_package/link_core/security.py | 26 ++++++++++++++++++++++++++ mcp_package/link_mcp/server.py | 6 ++++++ tests/test_link_cli.py | 4 +++- tests/test_mcp_contract.py | 4 +++- 8 files changed, 54 insertions(+), 20 deletions(-) create mode 100644 mcp_package/link_core/security.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eed083..23f70b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Improved memory recall ranking so project-matched and reviewed memories win ties while archived/stale memories rank lower when explicitly included. - Added `link.py capture-session` to save long session notes under `raw/memory-captures/` and return proposal-only memory candidates for human approval. - Added MCP `capture_session` so agents can preserve long session notes locally before asking which memory proposals to write. +- Added secret-looking content warnings to CLI and MCP session capture results so pasted tokens can be redacted from local raw notes. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/README.md b/README.md index 1bb84c2..d8f2e42 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,8 @@ python3 ~/link/link.py capture-session session-notes.md ~/link --project link ``` This stores the note under `raw/memory-captures/`, logs the capture locally, and -returns memory proposals for human approval. +returns memory proposals for human approval. Capture results warn on +secret-looking pasted values so you can redact the local raw note. Maintain the wiki: @@ -369,7 +370,7 @@ Most agents should start with: | `explain_memory` | You need provenance and review state for a memory. | | `remember_memory` | The user explicitly approves saving a durable memory. | | `propose_memories` | You want memory candidates from chat/session notes without writing. | -| `capture_session` | You want to save long session notes locally before approving memory writes. | +| `capture_session` | You want to save long session notes locally before approving memory writes; results include secret-looking content warnings. | Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, diff --git a/link.py b/link.py index be9d0b9..d6cb28f 100644 --- a/link.py +++ b/link.py @@ -56,17 +56,6 @@ "id_ed25519", "service-account*.json", ) -SECRET_VALUE_PATTERNS = ( - ("Anthropic API key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b")), - ("OpenAI API key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")), - ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")), - ("AWS access key", re.compile(r"\bA[SK]IA[0-9A-Z]{16}\b")), - ("PyPI token", re.compile(r"\bpypi-[A-Za-z0-9_-]{20,}\b")), - ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")), - ("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b")), - ("Stripe live secret key", re.compile(r"\bsk_live_[A-Za-z0-9]{20,}\b")), - ("Private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), -) SKIP_SCAN_DIRS = { ".git", "__pycache__", @@ -123,6 +112,9 @@ frontmatter_string as _frontmatter_string, parse_frontmatter as _parse_frontmatter, ) +from link_core.security import ( + secret_value_warnings as _secret_value_warnings, +) from link_core.wiki import ( build_backlinks as _core_build_backlinks, ) @@ -1215,10 +1207,9 @@ def _find_sensitive_values(target: Path) -> list[str]: text = path.read_text(encoding="utf-8", errors="replace") except OSError: continue - for label, pattern in SECRET_VALUE_PATTERNS: - if pattern.search(text): - matches.append(f"{path.relative_to(target)} ({label})") - break + warnings = _secret_value_warnings(text) + if warnings: + matches.append(f"{path.relative_to(target)} ({warnings[0]})") return sorted(matches) @@ -1674,6 +1665,7 @@ def capture_session( timestamp = _utc_timestamp() project_name = project or _default_project(root) capture_title = _capture_title(text, source, title) + secret_warnings = _secret_value_warnings(text) capture_dir = root / "raw" / "memory-captures" capture_dir.mkdir(parents=True, exist_ok=True) capture_path = _capture_filename(timestamp, capture_title, capture_dir) @@ -1713,6 +1705,7 @@ def capture_session( "source_input": source, "title": capture_title, "project": project_name, + "secret_warnings": secret_warnings, "proposals": result, } _append_log( @@ -1723,6 +1716,7 @@ def capture_session( [ f"Source input: {source}", f"Project: {project_name or 'none'}", + f"Secret warnings: {', '.join(secret_warnings) if secret_warnings else 'none'}", f"Proposals: {result['count']}", ], ) @@ -1735,6 +1729,8 @@ def capture_session( print(f"Path: {rel_path}") if project_name: print(f"Project: {project_name}") + if secret_warnings: + print("Secret-looking content: " + ", ".join(secret_warnings)) print(f"Proposals: {result['count']}") if not result["proposals"]: print("No durable memory candidates found.") diff --git a/mcp_package/README.md b/mcp_package/README.md index 91128c8..df23432 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -89,7 +89,7 @@ Custom wiki path: | `recall_memory(query, limit?, include_archived?, project?)` | Search durable local memories for preferences, decisions, and project context. | | `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?, project?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. | | `propose_memories(text, source?, limit?, project?)` | Propose durable memories from chat/session notes without writing them. | -| `capture_session(text, title?, source?, limit?, project?)` | Save long chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates. | +| `capture_session(text, title?, source?, limit?, project?)` | Save long chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates plus secret-looking content warnings. | | `update_memory(identifier, memory, source?, allow_conflict?, project?)` | Merge new information into an existing memory, blocking likely conflicts with other active memories by default. | | `archive_memory(identifier, reason?)` | Archive stale or wrong memory without deleting the Markdown page. | | `restore_memory(identifier)` | Restore archived memory to active status. | diff --git a/mcp_package/link_core/security.py b/mcp_package/link_core/security.py new file mode 100644 index 0000000..77a6a09 --- /dev/null +++ b/mcp_package/link_core/security.py @@ -0,0 +1,26 @@ +"""Local security hygiene helpers for Link.""" +from __future__ import annotations + +import re + + +SECRET_VALUE_PATTERNS = ( + ("Anthropic API key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b")), + ("OpenAI API key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")), + ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b")), + ("AWS access key", re.compile(r"\bA[SK]IA[0-9A-Z]{16}\b")), + ("PyPI token", re.compile(r"\bpypi-[A-Za-z0-9_-]{20,}\b")), + ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")), + ("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b")), + ("Stripe live secret key", re.compile(r"\bsk_live_[A-Za-z0-9]{20,}\b")), + ("Private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")), +) + + +def secret_value_warnings(text: str) -> list[str]: + """Return labels for secret-looking values found in text.""" + warnings: list[str] = [] + for label, pattern in SECRET_VALUE_PATTERNS: + if pattern.search(text): + warnings.append(label) + return warnings diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 99254cd..f40dac7 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -100,6 +100,9 @@ frontmatter_string as _frontmatter_string, parse_frontmatter as _parse_frontmatter, ) +from link_core.security import ( + secret_value_warnings as _secret_value_warnings, +) from link_core.wiki import ( build_backlinks as _core_build_backlinks, build_wiki_cache as _core_build_wiki_cache, @@ -298,6 +301,7 @@ def _capture_session( project_name = _resolve_project(project) timestamp = _utc_timestamp() capture_title = _capture_title(clean_text, clean_source, _clean_text_input(title, max_len=200)) + secret_warnings = _secret_value_warnings(clean_text) root = WIKI_DIR.parent capture_dir = root / "raw" / "memory-captures" capture_dir.mkdir(parents=True, exist_ok=True) @@ -338,6 +342,7 @@ def _capture_session( [ f"Source input: {clean_source}", f"Project: {project_name or 'none'}", + f"Secret warnings: {', '.join(secret_warnings) if secret_warnings else 'none'}", f"Proposals: {proposals['count']}", ], ) @@ -348,6 +353,7 @@ def _capture_session( "source": clean_source, "title": capture_title, "project": project_name, + "secret_warnings": secret_warnings, "proposals": proposals, } diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 3636a70..3ae43cd 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -464,10 +464,11 @@ def test_capture_session_writes_raw_note_and_proposes_only(self): before_memories = list((target / "wiki/memories").glob("*.md")) out = StringIO() + fake_key = "sk-" + ("A" * 24) with redirect_stdout(out): code = link_cli.capture_session( target, - "Remember that the user prefers release branches for Link work.", + f"Remember that the user prefers release branches for Link work. Test key {fake_key}", title="Release workflow session", project="link", json_output=True, @@ -485,6 +486,7 @@ def test_capture_session_writes_raw_note_and_proposes_only(self): self.assertTrue(payload["path"].startswith("raw/memory-captures/")) self.assertIn('project: "link"', capture_text) self.assertIn("proposal-only", capture_text) + self.assertEqual(payload["secret_warnings"], ["OpenAI API key"]) self.assertGreaterEqual(payload["proposals"]["count"], 1) self.assertEqual(len(after_memories), len(before_memories)) self.assertIn("capture-session", log_text) diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 6572350..9daa87f 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -233,9 +233,10 @@ def test_memory_brief_contract(self): def test_capture_session_contract(self): before_memories = list((self.target / "wiki/memories").glob("*.md")) + fake_key = "sk-" + ("A" * 24) payload = json.loads(self.server.capture_session( - "Remember that the user prefers release branches for Link work.", + f"Remember that the user prefers release branches for Link work. Test key {fake_key}", title="Release workflow session", project="link", )) @@ -249,6 +250,7 @@ def test_capture_session_contract(self): self.assertEqual(payload["project"], "link") self.assertTrue(payload["path"].startswith("raw/memory-captures/")) self.assertIn('project: "link"', capture_text) + self.assertEqual(payload["secret_warnings"], ["OpenAI API key"]) self.assertGreaterEqual(payload["proposals"]["count"], 1) self.assertEqual(len(after_memories), len(before_memories)) self.assertIn("capture-session", log_text) From 33a9e13bec8c00488dc860e1ccb4e5ddb4ee5352 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:22:33 -0600 Subject: [PATCH 042/292] Add capture proposal acceptance Add link.py accept-capture to approve one proposal from a saved raw capture and write it through duplicate/conflict-safe memory creation. --- CHANGELOG.md | 1 + LINK.md | 1 + README.md | 7 + .../_shared/link-instructions-project.md | 1 + link.py | 167 ++++++++++++++++++ tests/test_link_cli.py | 38 ++++ 6 files changed, 215 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f70b1..f074df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link.py capture-session` to save long session notes under `raw/memory-captures/` and return proposal-only memory candidates for human approval. - Added MCP `capture_session` so agents can preserve long session notes locally before asking which memory proposals to write. - Added secret-looking content warnings to CLI and MCP session capture results so pasted tokens can be redacted from local raw notes. +- Added `link.py accept-capture` to turn an approved raw-capture proposal into a durable memory through duplicate/conflict-safe writes. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index 2c2259a..1f05cfc 100644 --- a/LINK.md +++ b/LINK.md @@ -290,6 +290,7 @@ Rules: - For `scope: project`, include a project key when you know it. `link.py` infers this from repo-local installs; otherwise pass `--project ` or MCP `project`. - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. +- When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index `. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. diff --git a/README.md b/README.md index d8f2e42..b4fdfe7 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,12 @@ This stores the note under `raw/memory-captures/`, logs the capture locally, and returns memory proposals for human approval. Capture results warn on secret-looking pasted values so you can redact the local raw note. +Approve one proposal when it is right: + +```bash +python3 ~/link/link.py accept-capture raw/memory-captures/.md ~/link --index 1 +``` + Maintain the wiki: ```bash @@ -418,6 +424,7 @@ Common endpoints: | `python3 link.py remember "text" [--project slug]` | Save a local agent memory; strong duplicates and likely conflicts are refused unless explicitly allowed. | | `python3 link.py propose-memories [--project slug]` | Propose durable memories from notes without writing them. | | `python3 link.py capture-session [--project slug]` | Save chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates. | +| `python3 link.py accept-capture [--index N]` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | | `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | | `python3 link.py recall "query" [--project slug]` | Search local agent memories. | | `python3 link.py profile [--project slug]` | Show what Link remembers by type, scope, status, and recency. | diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index 953e2a4..2ed0ae8 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -5,6 +5,7 @@ This project has a Link wiki. Raw sources live in `raw/`, compiled wiki pages in When starting project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `python3 link.py brief "" .`. Project installs infer the current repo as the memory project key, so project-scoped memories stay separate from other repos while broad user memories still apply. For long session notes, use `python3 link.py capture-session "" .` to store a local raw capture and produce memory proposals without writing durable memories. +When the human approves a proposal from a capture, use `python3 link.py accept-capture "" . --index `. When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `LINK.md` for instructions and follow the protocol. diff --git a/link.py b/link.py index d6cb28f..b017f0c 100644 --- a/link.py +++ b/link.py @@ -1749,6 +1749,146 @@ def capture_session( return 0 +def _resolve_capture_file(root: Path, capture: str) -> Path | None: + raw = capture.strip() + if not raw: + return None + candidates = [Path(raw).expanduser()] + if not Path(raw).is_absolute(): + candidates.extend([ + root / raw, + root / "raw" / "memory-captures" / raw, + root / "raw" / "memory-captures" / f"{raw}.md", + ]) + for candidate in candidates: + try: + resolved = candidate.resolve() + except OSError: + continue + if not resolved.is_file(): + continue + try: + resolved.relative_to(root) + except ValueError: + continue + return resolved + return None + + +def _capture_notes_from_markdown(text: str) -> tuple[dict[str, object], str]: + meta, body = _parse_frontmatter(text) + match = re.search(r"^## Notes\s*(.*?)(?=^## |\Z)", body, flags=re.MULTILINE | re.DOTALL) + notes = match.group(1).strip() if match else body.strip() + return meta, notes + + +def accept_capture( + target: Path, + capture: str, + index: int = 1, + title: str | None = None, + memory_type: str | None = None, + scope: str | None = None, + tags: str | None = None, + project: str | None = None, + allow_duplicate: bool = False, + allow_conflict: bool = False, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + root = _resolve_link_root(target) + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + capture_path = _resolve_capture_file(root, capture) + if capture_path is None: + print(f"Capture not found under {root}: {capture}", file=sys.stderr) + return 1 + if index < 1: + print("Proposal index must be 1 or greater", file=sys.stderr) + return 1 + + raw_text = capture_path.read_text(encoding="utf-8", errors="replace") + meta, notes = _capture_notes_from_markdown(raw_text) + if not notes: + print(f"Capture has no notes: {capture_path}", file=sys.stderr) + return 1 + + rel_path = capture_path.relative_to(root).as_posix() + project_name = project or str(meta.get("project") or "") or _default_project(root) + proposals = _propose_memories_from_text( + wiki_dir, + notes, + source=rel_path, + limit=max(1, min(max(index, 10), 50)), + project=project_name, + ) + if index > len(proposals["proposals"]): + print(f"Capture has {len(proposals['proposals'])} proposal(s); index {index} is unavailable", file=sys.stderr) + return 1 + proposal = proposals["proposals"][index - 1] + chosen_scope = scope or str(proposal["scope"]) + chosen_project = project_name if chosen_scope == "project" else "" + result = _write_memory_page( + target, + str(proposal["memory"]), + title=title or str(proposal["title"]), + memory_type=memory_type or str(proposal["memory_type"]), + scope=chosen_scope, + tags=tags, + source=rel_path, + allow_duplicate=allow_duplicate, + allow_conflict=allow_conflict, + project=chosen_project, + ) + payload = { + "accepted": bool(result.get("created")), + "capture": rel_path, + "proposal_index": index, + "proposal": proposal, + "result": result, + } + if result.get("created"): + _append_log( + wiki_dir, + _utc_timestamp(), + "accept-capture", + f"Accepted proposal {index} from {rel_path}", + [ + f"Memory: {result['path']}", + f"Project: {result.get('project') or 'none'}", + ], + ) + + if json_output: + print(json.dumps(payload, indent=2)) + return 0 if payload["accepted"] else 1 + + if not payload["accepted"]: + duplicate_candidates = result.get("duplicate_candidates") or result.get("candidates") + if duplicate_candidates: + first = duplicate_candidates[0] + print(f"Duplicate candidate: {first['title']} ({first['path']})") + elif result.get("conflict_candidates"): + first = result["conflict_candidates"][0] + print(f"Conflict candidate: {first['title']} ({first['path']})") + else: + print("Capture proposal was not accepted.") + return 1 + + print("Capture proposal accepted") + print(f"Capture: {rel_path}") + print(f"Proposal: {index}") + print(f"Memory: {result['path']}") + if result.get("project"): + print(f"Project: {result['project']}") + print("") + print("Next:") + print(f" python3 link.py review-memory \"{result['name']}\" .") + return 0 + + def update_memory( target: Path, identifier: str, @@ -2397,6 +2537,19 @@ def main(argv: list[str] | None = None) -> int: capture_cmd.add_argument("--project", default=None, help="project key for proposal checks") capture_cmd.add_argument("--json", action="store_true", help="print machine-readable capture details") + accept_capture_cmd = sub.add_parser("accept-capture", help="accept one proposal from a raw session capture") + accept_capture_cmd.add_argument("capture", help="raw capture path or filename") + accept_capture_cmd.add_argument("target", nargs="?", default=".") + accept_capture_cmd.add_argument("--index", type=int, default=1, help="1-based proposal index to accept") + accept_capture_cmd.add_argument("--title", default=None, help="override accepted memory title") + accept_capture_cmd.add_argument("--type", dest="memory_type", choices=MEMORY_TYPES, default=None) + accept_capture_cmd.add_argument("--scope", choices=MEMORY_SCOPES, default=None) + accept_capture_cmd.add_argument("--tags", default=None, help="comma-separated tags") + accept_capture_cmd.add_argument("--project", default=None, help="project key for accepted project memory") + accept_capture_cmd.add_argument("--allow-duplicate", action="store_true", help="create a new memory even if a strong duplicate exists") + accept_capture_cmd.add_argument("--allow-conflict", action="store_true", help="create a memory even if it may conflict with an active memory") + accept_capture_cmd.add_argument("--json", action="store_true", help="print machine-readable acceptance details") + update_memory_cmd = sub.add_parser("update-memory", help="merge new text into an existing memory") update_memory_cmd.add_argument("identifier", help="memory page name, title, or path") update_memory_cmd.add_argument("text", help="new memory text to merge") @@ -2502,6 +2655,20 @@ def main(argv: list[str] | None = None) -> int: project=args.project, json_output=args.json, ) + if args.command == "accept-capture": + return accept_capture( + Path(args.target), + args.capture, + index=args.index, + title=args.title, + memory_type=args.memory_type, + scope=args.scope, + tags=args.tags, + project=args.project, + allow_duplicate=args.allow_duplicate, + allow_conflict=args.allow_conflict, + json_output=args.json, + ) if args.command == "update-memory": return update_memory( Path(args.target), diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 3ae43cd..dad2564 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -491,6 +491,44 @@ def test_capture_session_writes_raw_note_and_proposes_only(self): self.assertEqual(len(after_memories), len(before_memories)) self.assertIn("capture-session", log_text) + def test_accept_capture_writes_approved_proposal(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + + capture_out = StringIO() + with redirect_stdout(capture_out): + capture_code = link_cli.capture_session( + target, + "We decided to keep session capture approval local and explicit.", + title="Capture approval session", + project="link", + json_output=True, + ) + capture = json.loads(capture_out.getvalue()) + + accept_out = StringIO() + with redirect_stdout(accept_out): + accept_code = link_cli.accept_capture( + target, + capture["path"], + index=1, + json_output=True, + ) + accepted = json.loads(accept_out.getvalue()) + memory_path = target / accepted["result"]["path"] + memory_text = memory_path.read_text(encoding="utf-8") + log_text = (target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertEqual(capture_code, 0) + self.assertEqual(accept_code, 0) + self.assertTrue(accepted["accepted"]) + self.assertEqual(accepted["capture"], capture["path"]) + self.assertTrue(accepted["result"]["created"]) + self.assertIn(f'source: "{capture["path"]}"', memory_text) + self.assertIn("session capture approval", memory_text) + self.assertIn("accept-capture", log_text) + def test_memory_inbox_and_review_memory(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" From bb9fc6059f9c837928c70aff868573f2c99cd1d7 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:25:04 -0600 Subject: [PATCH 043/292] Add MCP capture acceptance Expose accept_capture over MCP so agents can approve saved raw-capture proposals through duplicate/conflict-safe memory creation. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 3 +- mcp_package/README.md | 3 +- mcp_package/link_mcp/server.py | 142 +++++++++++++++++++++++++++++++++ tests/test_mcp_contract.py | 19 +++++ 6 files changed, 167 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f074df6..bff7c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added MCP `capture_session` so agents can preserve long session notes locally before asking which memory proposals to write. - Added secret-looking content warnings to CLI and MCP session capture results so pasted tokens can be redacted from local raw notes. - Added `link.py accept-capture` to turn an approved raw-capture proposal into a durable memory through duplicate/conflict-safe writes. +- Added MCP `accept_capture` for approving saved capture proposals through the same duplicate/conflict-safe workflow. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index 1f05cfc..904546f 100644 --- a/LINK.md +++ b/LINK.md @@ -290,7 +290,7 @@ Rules: - For `scope: project`, include a project key when you know it. `link.py` infers this from repo-local installs; otherwise pass `--project ` or MCP `project`. - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. -- When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index `. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. +- When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index ` or MCP `accept_capture`. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. diff --git a/README.md b/README.md index b4fdfe7..1767670 100644 --- a/README.md +++ b/README.md @@ -377,10 +377,11 @@ Most agents should start with: | `remember_memory` | The user explicitly approves saving a durable memory. | | `propose_memories` | You want memory candidates from chat/session notes without writing. | | `capture_session` | You want to save long session notes locally before approving memory writes; results include secret-looking content warnings. | +| `accept_capture` | The user approves one proposal from a saved raw capture. | Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, -`propose_memories`, `capture_session`, `update_memory`, `archive_memory`, `restore_memory`, +`propose_memories`, `capture_session`, `accept_capture`, `update_memory`, `archive_memory`, `restore_memory`, `get_context`, `get_pages`, `get_backlinks`, `get_graph`, `rebuild_backlinks`. Memory write tools return `duplicate_candidates` or `conflict_candidates` when diff --git a/mcp_package/README.md b/mcp_package/README.md index df23432..6a573ad 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -90,6 +90,7 @@ Custom wiki path: | `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?, project?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. | | `propose_memories(text, source?, limit?, project?)` | Propose durable memories from chat/session notes without writing them. | | `capture_session(text, title?, source?, limit?, project?)` | Save long chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates plus secret-looking content warnings. | +| `accept_capture(capture, index?, title?, memory_type?, scope?, tags?, project?, allow_duplicate?, allow_conflict?)` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | | `update_memory(identifier, memory, source?, allow_conflict?, project?)` | Merge new information into an existing memory, blocking likely conflicts with other active memories by default. | | `archive_memory(identifier, reason?)` | Archive stale or wrong memory without deleting the Markdown page. | | `restore_memory(identifier)` | Restore archived memory to active status. | @@ -100,7 +101,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. If `remember_memory` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. +Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. Use `accept_capture` only after the user approves one captured proposal. If `remember_memory` or `accept_capture` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index f40dac7..cfebf0f 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -358,6 +358,114 @@ def _capture_session( } +def _resolve_capture_file(capture: str) -> Path | None: + root = WIKI_DIR.parent + raw = _clean_text_input(capture, max_len=500) + if not raw: + return None + candidates = [Path(raw).expanduser()] + if not Path(raw).is_absolute(): + candidates.extend([ + root / raw, + root / "raw" / "memory-captures" / raw, + root / "raw" / "memory-captures" / f"{raw}.md", + ]) + for candidate in candidates: + try: + resolved = candidate.resolve() + except OSError: + continue + if not resolved.is_file(): + continue + try: + resolved.relative_to(root) + except ValueError: + continue + return resolved + return None + + +def _capture_notes_from_markdown(text: str) -> tuple[dict[str, object], str]: + meta, body = _parse_frontmatter(text) + match = re.search(r"^## Notes\s*(.*?)(?=^## |\Z)", body, flags=re.MULTILINE | re.DOTALL) + notes = match.group(1).strip() if match else body.strip() + return meta, notes + + +def _accept_capture( + capture: str, + index: int = 1, + title: str = "", + memory_type: str = "", + scope: str = "", + tags: str = "", + project: str = "", + allow_duplicate: bool = False, + allow_conflict: bool = False, +) -> dict[str, object]: + try: + proposal_index = int(index) + except (TypeError, ValueError): + raise ValueError("proposal index must be an integer") + if proposal_index < 1: + raise ValueError("proposal index must be 1 or greater") + + root = WIKI_DIR.parent + capture_path = _resolve_capture_file(capture) + if capture_path is None: + raise ValueError(f"capture not found: {_clean_text_input(capture, max_len=500)}") + raw_text = capture_path.read_text(encoding="utf-8", errors="replace") + meta, notes = _capture_notes_from_markdown(raw_text) + if not notes: + raise ValueError("capture has no notes") + + rel_path = capture_path.relative_to(root).as_posix() + project_name = _core_slugify( + _clean_text_input(project) or str(meta.get("project") or "") or _default_project(), + fallback="", + ) + proposals = _propose_memories_from_text( + notes, + source=rel_path, + limit=max(1, min(max(proposal_index, 10), 50)), + project=project_name, + ) + if proposal_index > len(proposals["proposals"]): + raise ValueError(f"capture has {len(proposals['proposals'])} proposal(s); index {proposal_index} is unavailable") + proposal = proposals["proposals"][proposal_index - 1] + chosen_scope = _clean_text_input(scope).lower() or str(proposal["scope"]) + chosen_project = project_name if chosen_scope == "project" else "" + result = _write_memory_page( + str(proposal["memory"]), + title=_clean_text_input(title) or str(proposal["title"]), + memory_type=_clean_text_input(memory_type).lower() or str(proposal["memory_type"]), + scope=chosen_scope, + tags=tags, + source=rel_path, + allow_duplicate=allow_duplicate, + allow_conflict=allow_conflict, + project=chosen_project, + ) + payload = { + "accepted": bool(result.get("created")), + "capture": rel_path, + "proposal_index": proposal_index, + "proposal": proposal, + "result": result, + } + if result.get("created"): + _append_log( + _utc_timestamp(), + "accept-capture", + f"Accepted proposal {proposal_index} from {rel_path}", + [ + f"Memory: {result['path']}", + f"Project: {result.get('project') or 'none'}", + ], + ) + return payload + + def _append_log(timestamp: str, operation: str, description: str, lines: list[str]) -> None: log_path = WIKI_DIR / "log.md" if not log_path.exists(): @@ -585,6 +693,40 @@ def capture_session(text: str, title: str = "", source: str = "mcp", limit: int return json.dumps(result, ensure_ascii=False) +@mcp.tool() +def accept_capture( + capture: str, + index: int = 1, + title: str = "", + memory_type: str = "", + scope: str = "", + tags: str = "", + project: str = "", + allow_duplicate: bool = False, + allow_conflict: bool = False, +) -> str: + """Accept one proposal from a saved raw session capture. + + Recomputes proposals from raw/memory-captures, selects the 1-based index, + and writes the chosen memory through duplicate/conflict-safe creation. + """ + try: + result = _accept_capture( + capture, + index=index, + title=title, + memory_type=memory_type, + scope=scope, + tags=tags, + project=project, + allow_duplicate=allow_duplicate, + allow_conflict=allow_conflict, + ) + except ValueError as exc: + return json.dumps({"accepted": False, "error": str(exc)}) + return json.dumps(result, ensure_ascii=False) + + @mcp.tool() def memory_profile(limit: int = 10, project: str = "") -> str: """Summarize what Link currently remembers. diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 9daa87f..b3e545d 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -255,6 +255,25 @@ def test_capture_session_contract(self): self.assertEqual(len(after_memories), len(before_memories)) self.assertIn("capture-session", log_text) + def test_accept_capture_contract(self): + capture = json.loads(self.server.capture_session( + "We decided to keep MCP capture approval local and explicit.", + title="MCP capture approval session", + project="link", + )) + + accepted = json.loads(self.server.accept_capture(capture["path"], index=1)) + memory_path = self.target / accepted["result"]["path"] + memory_text = memory_path.read_text(encoding="utf-8") + log_text = (self.target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertTrue(accepted["accepted"]) + self.assertEqual(accepted["capture"], capture["path"]) + self.assertTrue(accepted["result"]["created"]) + self.assertIn(f'source: "{capture["path"]}"', memory_text) + self.assertIn("MCP capture approval", memory_text) + self.assertIn("accept-capture", log_text) + def test_memory_inbox_and_review_memory_contract(self): inbox = json.loads(self.server.memory_inbox()) reviewed = json.loads(self.server.review_memory( From 4f30653c813d85895a2c7f1a29f3841855cf8b72 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:28:00 -0600 Subject: [PATCH 044/292] Add raw capture redaction Add link.py redact-capture and shared secret redaction helpers so users can remove secret-looking values from saved raw captures while logging only labels and counts. --- CHANGELOG.md | 1 + LINK.md | 1 + README.md | 7 ++ .../_shared/link-instructions-project.md | 1 + link.py | 67 +++++++++++++++++++ mcp_package/link_core/security.py | 13 ++++ tests/test_link_cli.py | 31 +++++++++ 7 files changed, 121 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff7c00..3b6e215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added secret-looking content warnings to CLI and MCP session capture results so pasted tokens can be redacted from local raw notes. - Added `link.py accept-capture` to turn an approved raw-capture proposal into a durable memory through duplicate/conflict-safe writes. - Added MCP `accept_capture` for approving saved capture proposals through the same duplicate/conflict-safe workflow. +- Added `link.py redact-capture` to replace secret-looking values in saved raw captures while logging only warning labels and counts. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index 904546f..3ac4c73 100644 --- a/LINK.md +++ b/LINK.md @@ -291,6 +291,7 @@ Rules: - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. - When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index ` or MCP `accept_capture`. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. +- If capture results report `secret_warnings`, ask the human whether to redact the raw capture. Use `python3 link.py redact-capture "" .`; it replaces secret-looking values and logs labels/counts only. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. diff --git a/README.md b/README.md index 1767670..c5a6f02 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,12 @@ Approve one proposal when it is right: python3 ~/link/link.py accept-capture raw/memory-captures/.md ~/link --index 1 ``` +Redact a capture if Link warns about pasted secrets: + +```bash +python3 ~/link/link.py redact-capture raw/memory-captures/.md ~/link +``` + Maintain the wiki: ```bash @@ -426,6 +432,7 @@ Common endpoints: | `python3 link.py propose-memories [--project slug]` | Propose durable memories from notes without writing them. | | `python3 link.py capture-session [--project slug]` | Save chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates. | | `python3 link.py accept-capture [--index N]` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | +| `python3 link.py redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | | `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | | `python3 link.py recall "query" [--project slug]` | Search local agent memories. | | `python3 link.py profile [--project slug]` | Show what Link remembers by type, scope, status, and recency. | diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index 2ed0ae8..06595ec 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -6,6 +6,7 @@ When starting project-specific work, prime yourself with Link first: use MCP `me For long session notes, use `python3 link.py capture-session "" .` to store a local raw capture and produce memory proposals without writing durable memories. When the human approves a proposal from a capture, use `python3 link.py accept-capture "" . --index `. +If a capture reports secret warnings, ask before running `python3 link.py redact-capture "" .`. When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `LINK.md` for instructions and follow the protocol. diff --git a/link.py b/link.py index b017f0c..ea3ae4b 100644 --- a/link.py +++ b/link.py @@ -113,6 +113,7 @@ parse_frontmatter as _parse_frontmatter, ) from link_core.security import ( + redact_secret_values as _redact_secret_values, secret_value_warnings as _secret_value_warnings, ) from link_core.wiki import ( @@ -1889,6 +1890,59 @@ def accept_capture( return 0 +def redact_capture( + target: Path, + capture: str, + replacement: str = "[redacted-secret]", + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + root = _resolve_link_root(target) + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + capture_path = _resolve_capture_file(root, capture) + if capture_path is None: + print(f"Capture not found under {root}: {capture}", file=sys.stderr) + return 1 + + original = capture_path.read_text(encoding="utf-8", errors="replace") + redacted, labels, replacement_count = _redact_secret_values(original, replacement=replacement) + rel_path = capture_path.relative_to(root).as_posix() + if replacement_count: + capture_path.write_text(redacted, encoding="utf-8") + _append_log( + wiki_dir, + _utc_timestamp(), + "redact-capture", + f"Redacted secret-looking values from {rel_path}", + [ + f"Labels: {', '.join(labels)}", + f"Replacement count: {replacement_count}", + ], + ) + payload = { + "redacted": bool(replacement_count), + "path": rel_path, + "labels": labels, + "replacement_count": replacement_count, + } + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + + if replacement_count: + print("Capture redacted") + print(f"Path: {rel_path}") + print("Labels: " + ", ".join(labels)) + print(f"Replacement count: {replacement_count}") + else: + print("No secret-looking values found.") + print(f"Path: {rel_path}") + return 0 + + def update_memory( target: Path, identifier: str, @@ -2550,6 +2604,12 @@ def main(argv: list[str] | None = None) -> int: accept_capture_cmd.add_argument("--allow-conflict", action="store_true", help="create a memory even if it may conflict with an active memory") accept_capture_cmd.add_argument("--json", action="store_true", help="print machine-readable acceptance details") + redact_capture_cmd = sub.add_parser("redact-capture", help="redact secret-looking values from a raw session capture") + redact_capture_cmd.add_argument("capture", help="raw capture path or filename") + redact_capture_cmd.add_argument("target", nargs="?", default=".") + redact_capture_cmd.add_argument("--replacement", default="[redacted-secret]", help="replacement text") + redact_capture_cmd.add_argument("--json", action="store_true", help="print machine-readable redaction details") + update_memory_cmd = sub.add_parser("update-memory", help="merge new text into an existing memory") update_memory_cmd.add_argument("identifier", help="memory page name, title, or path") update_memory_cmd.add_argument("text", help="new memory text to merge") @@ -2669,6 +2729,13 @@ def main(argv: list[str] | None = None) -> int: allow_conflict=args.allow_conflict, json_output=args.json, ) + if args.command == "redact-capture": + return redact_capture( + Path(args.target), + args.capture, + replacement=args.replacement, + json_output=args.json, + ) if args.command == "update-memory": return update_memory( Path(args.target), diff --git a/mcp_package/link_core/security.py b/mcp_package/link_core/security.py index 77a6a09..34f1b7a 100644 --- a/mcp_package/link_core/security.py +++ b/mcp_package/link_core/security.py @@ -24,3 +24,16 @@ def secret_value_warnings(text: str) -> list[str]: if pattern.search(text): warnings.append(label) return warnings + + +def redact_secret_values(text: str, replacement: str = "[redacted-secret]") -> tuple[str, list[str], int]: + """Replace secret-looking values and return redacted text, labels, and count.""" + labels: list[str] = [] + total = 0 + redacted = text + for label, pattern in SECRET_VALUE_PATTERNS: + redacted, count = pattern.subn(replacement, redacted) + if count: + labels.append(label) + total += count + return redacted, labels, total diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index dad2564..e7b07e9 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -529,6 +529,37 @@ def test_accept_capture_writes_approved_proposal(self): self.assertIn("session capture approval", memory_text) self.assertIn("accept-capture", log_text) + def test_redact_capture_replaces_secret_like_values(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + fake_key = "sk-" + ("B" * 24) + + capture_out = StringIO() + with redirect_stdout(capture_out): + link_cli.capture_session( + target, + f"Remember that capture redaction stays local. Test key {fake_key}", + title="Capture redaction session", + json_output=True, + ) + capture = json.loads(capture_out.getvalue()) + + redact_out = StringIO() + with redirect_stdout(redact_out): + code = link_cli.redact_capture(target, capture["path"], json_output=True) + redacted = json.loads(redact_out.getvalue()) + capture_text = (target / capture["path"]).read_text(encoding="utf-8") + log_text = (target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertEqual(code, 0) + self.assertTrue(redacted["redacted"]) + self.assertEqual(redacted["labels"], ["OpenAI API key"]) + self.assertNotIn(fake_key, capture_text) + self.assertIn("[redacted-secret]", capture_text) + self.assertIn("redact-capture", log_text) + self.assertNotIn(fake_key, log_text) + def test_memory_inbox_and_review_memory(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" From 208dcc2240ae3f567c73f32265303fbfa324301c Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:29:57 -0600 Subject: [PATCH 045/292] Add MCP capture redaction Expose redact_capture over MCP so agents can redact secret-looking values from saved raw captures after user approval without logging secrets. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 4 ++- mcp_package/README.md | 3 ++- mcp_package/link_mcp/server.py | 45 ++++++++++++++++++++++++++++++++++ tests/test_mcp_contract.py | 18 ++++++++++++++ 6 files changed, 70 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6e215..7ca6570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link.py accept-capture` to turn an approved raw-capture proposal into a durable memory through duplicate/conflict-safe writes. - Added MCP `accept_capture` for approving saved capture proposals through the same duplicate/conflict-safe workflow. - Added `link.py redact-capture` to replace secret-looking values in saved raw captures while logging only warning labels and counts. +- Added MCP `redact_capture` so agents can redact saved raw captures after user approval. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index 3ac4c73..a5c86fd 100644 --- a/LINK.md +++ b/LINK.md @@ -291,7 +291,7 @@ Rules: - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. - When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index ` or MCP `accept_capture`. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. -- If capture results report `secret_warnings`, ask the human whether to redact the raw capture. Use `python3 link.py redact-capture "" .`; it replaces secret-looking values and logs labels/counts only. +- If capture results report `secret_warnings`, ask the human whether to redact the raw capture. Use `python3 link.py redact-capture "" .` or MCP `redact_capture`; it replaces secret-looking values and logs labels/counts only. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. diff --git a/README.md b/README.md index c5a6f02..93c9b23 100644 --- a/README.md +++ b/README.md @@ -384,10 +384,12 @@ Most agents should start with: | `propose_memories` | You want memory candidates from chat/session notes without writing. | | `capture_session` | You want to save long session notes locally before approving memory writes; results include secret-looking content warnings. | | `accept_capture` | The user approves one proposal from a saved raw capture. | +| `redact_capture` | The user approves redacting secret-looking values from a saved raw capture. | Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, -`propose_memories`, `capture_session`, `accept_capture`, `update_memory`, `archive_memory`, `restore_memory`, +`propose_memories`, `capture_session`, `accept_capture`, `redact_capture`, +`update_memory`, `archive_memory`, `restore_memory`, `get_context`, `get_pages`, `get_backlinks`, `get_graph`, `rebuild_backlinks`. Memory write tools return `duplicate_candidates` or `conflict_candidates` when diff --git a/mcp_package/README.md b/mcp_package/README.md index 6a573ad..f2eb8c9 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -91,6 +91,7 @@ Custom wiki path: | `propose_memories(text, source?, limit?, project?)` | Propose durable memories from chat/session notes without writing them. | | `capture_session(text, title?, source?, limit?, project?)` | Save long chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates plus secret-looking content warnings. | | `accept_capture(capture, index?, title?, memory_type?, scope?, tags?, project?, allow_duplicate?, allow_conflict?)` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | +| `redact_capture(capture, replacement?)` | Redact secret-looking values from a saved raw capture after user approval. | | `update_memory(identifier, memory, source?, allow_conflict?, project?)` | Merge new information into an existing memory, blocking likely conflicts with other active memories by default. | | `archive_memory(identifier, reason?)` | Archive stale or wrong memory without deleting the Markdown page. | | `restore_memory(identifier)` | Restore archived memory to active status. | @@ -101,7 +102,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. Use `accept_capture` only after the user approves one captured proposal. If `remember_memory` or `accept_capture` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. +Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. If `capture_session` reports secret warnings, ask before calling `redact_capture`. Use `accept_capture` only after the user approves one captured proposal. If `remember_memory` or `accept_capture` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index cfebf0f..9a9b52f 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -101,6 +101,7 @@ parse_frontmatter as _parse_frontmatter, ) from link_core.security import ( + redact_secret_values as _redact_secret_values, secret_value_warnings as _secret_value_warnings, ) from link_core.wiki import ( @@ -466,6 +467,36 @@ def _accept_capture( return payload +def _redact_capture(capture: str, replacement: str = "[redacted-secret]") -> dict[str, object]: + root = WIKI_DIR.parent + capture_path = _resolve_capture_file(capture) + if capture_path is None: + raise ValueError(f"capture not found: {_clean_text_input(capture, max_len=500)}") + original = capture_path.read_text(encoding="utf-8", errors="replace") + redacted, labels, replacement_count = _redact_secret_values( + original, + replacement=_clean_text_input(replacement, max_len=100) or "[redacted-secret]", + ) + rel_path = capture_path.relative_to(root).as_posix() + if replacement_count: + capture_path.write_text(redacted, encoding="utf-8") + _append_log( + _utc_timestamp(), + "redact-capture", + f"Redacted secret-looking values from {rel_path}", + [ + f"Labels: {', '.join(labels)}", + f"Replacement count: {replacement_count}", + ], + ) + return { + "redacted": bool(replacement_count), + "path": rel_path, + "labels": labels, + "replacement_count": replacement_count, + } + + def _append_log(timestamp: str, operation: str, description: str, lines: list[str]) -> None: log_path = WIKI_DIR / "log.md" if not log_path.exists(): @@ -727,6 +758,20 @@ def accept_capture( return json.dumps(result, ensure_ascii=False) +@mcp.tool() +def redact_capture(capture: str, replacement: str = "[redacted-secret]") -> str: + """Redact secret-looking values from a saved raw session capture. + + Use after capture_session returns secret_warnings and the user approves + redaction. Logs warning labels and counts only, never secret values. + """ + try: + result = _redact_capture(capture, replacement=replacement) + except ValueError as exc: + return json.dumps({"redacted": False, "error": str(exc)}) + return json.dumps(result, ensure_ascii=False) + + @mcp.tool() def memory_profile(limit: int = 10, project: str = "") -> str: """Summarize what Link currently remembers. diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index b3e545d..6b117da 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -274,6 +274,24 @@ def test_accept_capture_contract(self): self.assertIn("MCP capture approval", memory_text) self.assertIn("accept-capture", log_text) + def test_redact_capture_contract(self): + fake_key = "sk-" + ("C" * 24) + capture = json.loads(self.server.capture_session( + f"Remember that MCP capture redaction stays local. Test key {fake_key}", + title="MCP capture redaction session", + )) + + redacted = json.loads(self.server.redact_capture(capture["path"])) + capture_text = (self.target / capture["path"]).read_text(encoding="utf-8") + log_text = (self.target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertTrue(redacted["redacted"]) + self.assertEqual(redacted["labels"], ["OpenAI API key"]) + self.assertNotIn(fake_key, capture_text) + self.assertIn("[redacted-secret]", capture_text) + self.assertIn("redact-capture", log_text) + self.assertNotIn(fake_key, log_text) + def test_memory_inbox_and_review_memory_contract(self): inbox = json.loads(self.server.memory_inbox()) reviewed = json.loads(self.server.review_memory( From 0bcf1fcc7920f3a5bc7062a41dff10269bf881c0 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:32:10 -0600 Subject: [PATCH 046/292] Add confirmed capture deletion Add link.py delete-capture with an explicit confirmation gate so users can remove saved raw captures without logging capture contents. --- CHANGELOG.md | 1 + LINK.md | 1 + README.md | 2 + .../_shared/link-instructions-project.md | 1 + link.py | 61 +++++++++++++++++++ tests/test_link_cli.py | 36 +++++++++++ 6 files changed, 102 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca6570..b6a45a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added MCP `accept_capture` for approving saved capture proposals through the same duplicate/conflict-safe workflow. - Added `link.py redact-capture` to replace secret-looking values in saved raw captures while logging only warning labels and counts. - Added MCP `redact_capture` so agents can redact saved raw captures after user approval. +- Added `link.py delete-capture` with explicit confirmation for removing saved raw captures without logging capture contents. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index a5c86fd..fcf23c8 100644 --- a/LINK.md +++ b/LINK.md @@ -292,6 +292,7 @@ Rules: - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. - When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index ` or MCP `accept_capture`. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. - If capture results report `secret_warnings`, ask the human whether to redact the raw capture. Use `python3 link.py redact-capture "" .` or MCP `redact_capture`; it replaces secret-looking values and logs labels/counts only. +- If the human asks to remove a raw capture, run `python3 link.py delete-capture "" . --confirm`. Never delete captures without explicit confirmation. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. diff --git a/README.md b/README.md index 93c9b23..ab1975c 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,7 @@ Redact a capture if Link warns about pasted secrets: ```bash python3 ~/link/link.py redact-capture raw/memory-captures/.md ~/link +python3 ~/link/link.py delete-capture raw/memory-captures/.md ~/link --confirm ``` Maintain the wiki: @@ -435,6 +436,7 @@ Common endpoints: | `python3 link.py capture-session [--project slug]` | Save chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates. | | `python3 link.py accept-capture [--index N]` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | | `python3 link.py redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | +| `python3 link.py delete-capture --confirm` | Delete a saved raw capture after explicit confirmation. | | `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | | `python3 link.py recall "query" [--project slug]` | Search local agent memories. | | `python3 link.py profile [--project slug]` | Show what Link remembers by type, scope, status, and recency. | diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index 06595ec..aa6e5bd 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -7,6 +7,7 @@ When starting project-specific work, prime yourself with Link first: use MCP `me For long session notes, use `python3 link.py capture-session "" .` to store a local raw capture and produce memory proposals without writing durable memories. When the human approves a proposal from a capture, use `python3 link.py accept-capture "" . --index `. If a capture reports secret warnings, ask before running `python3 link.py redact-capture "" .`. +Only delete a raw capture after explicit confirmation: `python3 link.py delete-capture "" . --confirm`. When the user says **"remember"**, **"recall"**, **"ingest"**, **"query"**, **"lint"**, or **"research"**, read `LINK.md` for instructions and follow the protocol. diff --git a/link.py b/link.py index ea3ae4b..8d0bef4 100644 --- a/link.py +++ b/link.py @@ -1943,6 +1943,54 @@ def redact_capture( return 0 +def delete_capture( + target: Path, + capture: str, + confirm: bool = False, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + root = _resolve_link_root(target) + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + capture_path = _resolve_capture_file(root, capture) + if capture_path is None: + print(f"Capture not found under {root}: {capture}", file=sys.stderr) + return 1 + rel_path = capture_path.relative_to(root).as_posix() + payload = { + "deleted": False, + "path": rel_path, + "confirmation_required": not confirm, + } + if not confirm: + if json_output: + print(json.dumps(payload, indent=2)) + else: + print("Confirmation required.") + print(f"Run: python3 link.py delete-capture \"{rel_path}\" . --confirm") + return 1 + + capture_path.unlink() + _append_log( + wiki_dir, + _utc_timestamp(), + "delete-capture", + f"Deleted raw capture {rel_path}", + ["Deleted file only; capture contents were not logged."], + ) + payload["deleted"] = True + payload["confirmation_required"] = False + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + print("Capture deleted") + print(f"Path: {rel_path}") + return 0 + + def update_memory( target: Path, identifier: str, @@ -2610,6 +2658,12 @@ def main(argv: list[str] | None = None) -> int: redact_capture_cmd.add_argument("--replacement", default="[redacted-secret]", help="replacement text") redact_capture_cmd.add_argument("--json", action="store_true", help="print machine-readable redaction details") + delete_capture_cmd = sub.add_parser("delete-capture", help="delete a raw session capture after explicit confirmation") + delete_capture_cmd.add_argument("capture", help="raw capture path or filename") + delete_capture_cmd.add_argument("target", nargs="?", default=".") + delete_capture_cmd.add_argument("--confirm", action="store_true", help="required to delete the capture") + delete_capture_cmd.add_argument("--json", action="store_true", help="print machine-readable deletion details") + update_memory_cmd = sub.add_parser("update-memory", help="merge new text into an existing memory") update_memory_cmd.add_argument("identifier", help="memory page name, title, or path") update_memory_cmd.add_argument("text", help="new memory text to merge") @@ -2736,6 +2790,13 @@ def main(argv: list[str] | None = None) -> int: replacement=args.replacement, json_output=args.json, ) + if args.command == "delete-capture": + return delete_capture( + Path(args.target), + args.capture, + confirm=args.confirm, + json_output=args.json, + ) if args.command == "update-memory": return update_memory( Path(args.target), diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index e7b07e9..9331100 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -560,6 +560,42 @@ def test_redact_capture_replaces_secret_like_values(self): self.assertIn("redact-capture", log_text) self.assertNotIn(fake_key, log_text) + def test_delete_capture_requires_confirmation_and_removes_file(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + + capture_out = StringIO() + with redirect_stdout(capture_out): + link_cli.capture_session( + target, + "Remember that raw capture deletion requires confirmation.", + title="Capture deletion session", + json_output=True, + ) + capture = json.loads(capture_out.getvalue()) + capture_path = target / capture["path"] + + denied_out = StringIO() + with redirect_stdout(denied_out): + denied_code = link_cli.delete_capture(target, capture["path"], json_output=True) + denied = json.loads(denied_out.getvalue()) + self.assertTrue(capture_path.exists()) + + delete_out = StringIO() + with redirect_stdout(delete_out): + delete_code = link_cli.delete_capture(target, capture["path"], confirm=True, json_output=True) + deleted = json.loads(delete_out.getvalue()) + log_text = (target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertEqual(denied_code, 1) + self.assertFalse(denied["deleted"]) + self.assertEqual(delete_code, 0) + self.assertTrue(deleted["deleted"]) + self.assertFalse(capture_path.exists()) + self.assertIn("delete-capture", log_text) + self.assertNotIn("raw capture deletion requires confirmation", log_text) + def test_memory_inbox_and_review_memory(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" From cb08a21a6cd1d7dda27c908af30cba8d43e8eb1c Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:34:08 -0600 Subject: [PATCH 047/292] Add MCP capture deletion Expose delete_capture over MCP with an explicit confirmation gate and no capture-content logging. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 3 ++- mcp_package/README.md | 3 ++- mcp_package/link_mcp/server.py | 39 ++++++++++++++++++++++++++++++++++ tests/test_mcp_contract.py | 20 +++++++++++++++++ 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a45a1..3f4c0d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link.py redact-capture` to replace secret-looking values in saved raw captures while logging only warning labels and counts. - Added MCP `redact_capture` so agents can redact saved raw captures after user approval. - Added `link.py delete-capture` with explicit confirmation for removing saved raw captures without logging capture contents. +- Added MCP `delete_capture` with explicit confirmation for removing saved raw captures. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index fcf23c8..159ff4f 100644 --- a/LINK.md +++ b/LINK.md @@ -292,7 +292,7 @@ Rules: - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. - When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index ` or MCP `accept_capture`. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. - If capture results report `secret_warnings`, ask the human whether to redact the raw capture. Use `python3 link.py redact-capture "" .` or MCP `redact_capture`; it replaces secret-looking values and logs labels/counts only. -- If the human asks to remove a raw capture, run `python3 link.py delete-capture "" . --confirm`. Never delete captures without explicit confirmation. +- If the human asks to remove a raw capture, run `python3 link.py delete-capture "" . --confirm` or MCP `delete_capture` with `confirm: true`. Never delete captures without explicit confirmation. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. diff --git a/README.md b/README.md index ab1975c..060bbdc 100644 --- a/README.md +++ b/README.md @@ -386,10 +386,11 @@ Most agents should start with: | `capture_session` | You want to save long session notes locally before approving memory writes; results include secret-looking content warnings. | | `accept_capture` | The user approves one proposal from a saved raw capture. | | `redact_capture` | The user approves redacting secret-looking values from a saved raw capture. | +| `delete_capture` | The user explicitly confirms deleting a saved raw capture. | Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, -`propose_memories`, `capture_session`, `accept_capture`, `redact_capture`, +`propose_memories`, `capture_session`, `accept_capture`, `redact_capture`, `delete_capture`, `update_memory`, `archive_memory`, `restore_memory`, `get_context`, `get_pages`, `get_backlinks`, `get_graph`, `rebuild_backlinks`. diff --git a/mcp_package/README.md b/mcp_package/README.md index f2eb8c9..a8b7c07 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -92,6 +92,7 @@ Custom wiki path: | `capture_session(text, title?, source?, limit?, project?)` | Save long chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates plus secret-looking content warnings. | | `accept_capture(capture, index?, title?, memory_type?, scope?, tags?, project?, allow_duplicate?, allow_conflict?)` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | | `redact_capture(capture, replacement?)` | Redact secret-looking values from a saved raw capture after user approval. | +| `delete_capture(capture, confirm?)` | Delete a saved raw capture after explicit confirmation. | | `update_memory(identifier, memory, source?, allow_conflict?, project?)` | Merge new information into an existing memory, blocking likely conflicts with other active memories by default. | | `archive_memory(identifier, reason?)` | Archive stale or wrong memory without deleting the Markdown page. | | `restore_memory(identifier)` | Restore archived memory to active status. | @@ -102,7 +103,7 @@ Custom wiki path: | `get_graph()` | All nodes + edges for graph reasoning. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. If `capture_session` reports secret warnings, ask before calling `redact_capture`. Use `accept_capture` only after the user approves one captured proposal. If `remember_memory` or `accept_capture` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. +Start with `memory_brief`, passing the user's task as `query` when available. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. Use `memory_profile` to inspect the user/project memory shape, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. If `capture_session` reports secret warnings, ask before calling `redact_capture`. Use `accept_capture` only after the user approves one captured proposal. Use `delete_capture` only after explicit user confirmation. If `remember_memory` or `accept_capture` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `get_context` for source-backed topic answers — one call returns the primary page plus all related pages via graph traversal. ## Wiki location diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 9a9b52f..18cce47 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -497,6 +497,31 @@ def _redact_capture(capture: str, replacement: str = "[redacted-secret]") -> dic } +def _delete_capture(capture: str, confirm: bool = False) -> dict[str, object]: + root = WIKI_DIR.parent + capture_path = _resolve_capture_file(capture) + if capture_path is None: + raise ValueError(f"capture not found: {_clean_text_input(capture, max_len=500)}") + rel_path = capture_path.relative_to(root).as_posix() + payload = { + "deleted": False, + "path": rel_path, + "confirmation_required": not confirm, + } + if not confirm: + return payload + capture_path.unlink() + _append_log( + _utc_timestamp(), + "delete-capture", + f"Deleted raw capture {rel_path}", + ["Deleted file only; capture contents were not logged."], + ) + payload["deleted"] = True + payload["confirmation_required"] = False + return payload + + def _append_log(timestamp: str, operation: str, description: str, lines: list[str]) -> None: log_path = WIKI_DIR / "log.md" if not log_path.exists(): @@ -772,6 +797,20 @@ def redact_capture(capture: str, replacement: str = "[redacted-secret]") -> str: return json.dumps(result, ensure_ascii=False) +@mcp.tool() +def delete_capture(capture: str, confirm: bool = False) -> str: + """Delete a saved raw session capture after explicit user confirmation. + + The tool refuses to delete unless confirm is true. It logs the capture path + and deletion operation only, never the capture contents. + """ + try: + result = _delete_capture(capture, confirm=confirm) + except ValueError as exc: + return json.dumps({"deleted": False, "error": str(exc)}) + return json.dumps(result, ensure_ascii=False) + + @mcp.tool() def memory_profile(limit: int = 10, project: str = "") -> str: """Summarize what Link currently remembers. diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 6b117da..bc79ec8 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -292,6 +292,26 @@ def test_redact_capture_contract(self): self.assertIn("redact-capture", log_text) self.assertNotIn(fake_key, log_text) + def test_delete_capture_contract(self): + capture = json.loads(self.server.capture_session( + "Remember that MCP capture deletion requires confirmation.", + title="MCP capture deletion session", + )) + capture_path = self.target / capture["path"] + + denied = json.loads(self.server.delete_capture(capture["path"])) + self.assertFalse(denied["deleted"]) + self.assertTrue(denied["confirmation_required"]) + self.assertTrue(capture_path.exists()) + + deleted = json.loads(self.server.delete_capture(capture["path"], confirm=True)) + log_text = (self.target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertTrue(deleted["deleted"]) + self.assertFalse(capture_path.exists()) + self.assertIn("delete-capture", log_text) + self.assertNotIn("MCP capture deletion requires confirmation", log_text) + def test_memory_inbox_and_review_memory_contract(self): inbox = json.loads(self.server.memory_inbox()) reviewed = json.loads(self.server.review_memory( From da3d013ab171b3c7a2b928468f670984af8f61a8 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:37:54 -0600 Subject: [PATCH 048/292] Show raw captures in memory dashboard Surface saved raw captures, secret-warning counts, and accept/redact/delete commands in /memory and /api/memory-dashboard. --- CHANGELOG.md | 1 + README.md | 2 +- serve.py | 115 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_serve.py | 29 +++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4c0d9..946b0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added MCP `redact_capture` so agents can redact saved raw captures after user approval. - Added `link.py delete-capture` with explicit confirmation for removing saved raw captures without logging capture contents. - Added MCP `delete_capture` with explicit confirmation for removing saved raw captures. +- Added raw capture visibility to `/memory` and `/api/memory-dashboard`, including accept/redact/delete commands and secret-warning counts. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/README.md b/README.md index 060bbdc..9d0d52b 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,7 @@ Common endpoints: | Endpoint | Description | |----------|-------------| | `GET /api/pages` | All pages with title, type, tags, aliases, maturity, and TLDR. | -| `GET /api/memory-dashboard` | Read-only memory dashboard data. | +| `GET /api/memory-dashboard` | Read-only memory dashboard data, including saved raw captures and secret-warning counts. | | `GET /api/memory-profile` | Counts and recent memories for the local memory profile. | | `GET /api/memory-inbox` | Memories that need review or metadata cleanup. | | `GET /api/explain-memory?memory=` | Provenance, lifecycle, graph links, review state, and recall readiness. | diff --git a/serve.py b/serve.py index f011a80..aab341c 100644 --- a/serve.py +++ b/serve.py @@ -24,6 +24,9 @@ from link_core.frontmatter import ( parse_frontmatter as _parse_frontmatter, ) +from link_core.security import ( + secret_value_warnings as _secret_value_warnings, +) from link_core.wiki import ( build_backlinks as _core_build_backlinks, build_wiki_cache as _core_build_wiki_cache, @@ -236,8 +239,18 @@ def _memory_dashboard_next_actions( review_count: int, updated_count: int, archived_count: int, + capture_count: int = 0, + capture_warning_count: int = 0, ) -> list[dict[str, str]]: actions: list[dict[str, str]] = [] + if capture_warning_count: + actions.append({ + "label": "Redact capture warnings", + "detail": f"{capture_warning_count} raw capture{'s' if capture_warning_count != 1 else ''} contain secret-looking values.", + "href": "/memory", + "command": "python3 link.py redact-capture raw/memory-captures/.md .", + "priority": "high", + }) if review_count: memory_label = "memory" if review_count == 1 else "memories" verb = "needs" if review_count == 1 else "need" @@ -264,6 +277,14 @@ def _memory_dashboard_next_actions( "command": "python3 link.py profile .", "priority": "low", }) + if capture_count and not capture_warning_count: + actions.append({ + "label": "Review raw captures", + "detail": f"{capture_count} saved raw capture{'s' if capture_count != 1 else ''} can be accepted, redacted, or deleted.", + "href": "/memory", + "command": "python3 link.py accept-capture raw/memory-captures/.md . --index 1", + "priority": "medium", + }) if not memory_count: actions.append({ "label": "Create the first memory", @@ -283,6 +304,43 @@ def _memory_dashboard_next_actions( return actions[:3] +def _capture_records(limit: int = 12) -> list[dict[str, object]]: + root = WIKI_DIR.parent + capture_dir = RAW_DIR / "memory-captures" + if not capture_dir.exists(): + return [] + records: list[dict[str, object]] = [] + for path in sorted(capture_dir.rglob("*.md")): + if path.name.startswith("."): + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + stat = path.stat() + except OSError: + continue + meta, body = _parse_frontmatter(text) + title = str(meta.get("title") or path.stem) + warnings = _secret_value_warnings(text) + rel = path.relative_to(root).as_posix() + records.append({ + "path": rel, + "title": title, + "project": str(meta.get("project") or ""), + "date_captured": str(meta.get("date_captured") or ""), + "size_bytes": stat.st_size, + "secret_warnings": warnings, + "warning_count": len(warnings), + "snippet": re.sub(r"\s+", " ", body).strip()[:180], + "commands": { + "accept": f'python3 link.py accept-capture "{rel}" . --index 1', + "redact": f'python3 link.py redact-capture "{rel}" .', + "delete": f'python3 link.py delete-capture "{rel}" . --confirm', + }, + }) + records.sort(key=lambda item: (str(item["date_captured"]), str(item["path"])), reverse=True) + return records[:limit] + + def _memory_dashboard(limit: int = 12) -> dict[str, object]: limit = max(1, min(limit, 50)) records = _memory_records() @@ -305,12 +363,16 @@ def _memory_dashboard(limit: int = 12) -> dict[str, object]: review_count = inbox["review_count"] updated_count = len(recent_updates) archived_count = len(archived_records) + captures = _capture_records(limit=limit) + capture_warning_count = sum(1 for capture in captures if capture["warning_count"]) return { "memory_count": len(records), "active_count": len(active_records), "review_count": review_count, "archived_count": archived_count, "updated_count": updated_count, + "capture_count": len(captures), + "capture_warning_count": capture_warning_count, "by_type": _count_values(records, "memory_type"), "by_scope": _count_values(records, "scope"), "counts_by_severity": inbox["counts_by_severity"], @@ -319,11 +381,14 @@ def _memory_dashboard(limit: int = 12) -> dict[str, object]: review_count=review_count, updated_count=updated_count, archived_count=archived_count, + capture_count=len(captures), + capture_warning_count=capture_warning_count, ), "active": [_memory_with_actions(record) for record in recent_active[:limit]], "review": [_memory_with_actions(record) for record in inbox["items"][:limit]], "recent_updates": [_memory_with_actions(record) for record in recent_updates[:limit]], "archived": [_memory_with_actions(record) for record in archived[:limit]], + "captures": captures, } @@ -981,6 +1046,54 @@ def _render_memory_section(title: str, records: list[dict[str, object]], empty: return heading + f'
{cards}
' +def _render_capture_card(capture: dict[str, object]) -> str: + title = html.escape(str(capture.get("title") or capture.get("path") or "Raw capture")) + path = html.escape(str(capture.get("path") or "")) + meta_parts = ["raw capture"] + if capture.get("project"): + meta_parts.append(f'project {capture["project"]}') + if capture.get("date_captured"): + meta_parts.append(f'captured {capture["date_captured"]}') + warnings = [str(label) for label in capture.get("secret_warnings") or []] + if warnings: + meta_parts.append("secret warnings") + meta = " · ".join(meta_parts) + warning_html = "" + if warnings: + warning_html = ( + '

Secret-looking values: ' + + html.escape(", ".join(warnings)) + + "

" + ) + commands = capture.get("commands") or {} + actions = "".join( + f'
{html.escape(label)}{html.escape(str(command))}
' + for label, command in ( + ("Accept proposal", commands.get("accept", "")), + ("Redact", commands.get("redact", "")), + ("Delete", commands.get("delete", "")), + ) + if command + ) + return ( + '
' + f'

{title}

' + f'
{html.escape(meta)}
' + f'

{path}

' + f'{warning_html}' + f'
{actions}
' + '
' + ) + + +def _render_capture_section(captures: list[dict[str, object]]) -> str: + heading = '

Raw captures

' + if not captures: + return heading + "

No saved raw captures.

" + cards = "".join(_render_capture_card(capture) for capture in captures) + return heading + f'
{cards}
' + + def _render_memory_next_actions(actions: list[dict[str, str]]) -> str: items = "" for action in actions: @@ -1004,6 +1117,7 @@ def _render_memory_dashboard(): f'
{dashboard["active_count"]}active
' f'
{dashboard["review_count"]}review
' f'
{dashboard["updated_count"]}updated
' + f'
{dashboard["capture_count"]}captures
' f'
{dashboard["archived_count"]}archived
' f'' ) @@ -1025,6 +1139,7 @@ def _render_memory_dashboard(): f'{_render_memory_next_actions(dashboard["next_actions"])}' f'{counts}' f'{_render_memory_section("Review needed", dashboard["review"], "No memories need review.", href="/inbox", include_issues=True)}' + f'{_render_capture_section(dashboard["captures"])}' f'{_render_memory_section("Recent updates", dashboard["recent_updates"], "No memory updates yet.")}' f'{_render_memory_section("Active memories", dashboard["active"], "No active memories yet.", href="/profile")}' f'{_render_memory_section("Archived memories", dashboard["archived"], "No archived memories.")}' diff --git a/tests/test_serve.py b/tests/test_serve.py index 30338b2..d2264f8 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -239,6 +239,35 @@ def test_memory_dashboard_next_actions_uses_singular_memory_label(self): self.assertIn("1 memory needs confirmation", actions[0]["detail"]) self.assertNotIn("memoryy", actions[0]["detail"]) + def test_memory_dashboard_surfaces_raw_captures_and_secret_warnings(self): + wiki = self.make_wiki() + capture_dir = wiki.parent / "raw" / "memory-captures" + capture_dir.mkdir(parents=True) + fake_key = "sk-" + ("D" * 24) + (capture_dir / "session.md").write_text( + "---\n" + "title: \"Session capture\"\n" + "source_type: conversation\n" + "date_captured: \"2026-05-05T00:00:00Z\"\n" + "project: \"link\"\n" + "---\n\n" + "# Session capture\n\n" + "## Notes\n\n" + f"Remember that dashboard capture review is visible. Test key {fake_key}\n", + encoding="utf-8", + ) + + dashboard = serve._memory_dashboard(limit=8) + html = serve._render_memory_dashboard() + + self.assertEqual(dashboard["capture_count"], 1) + self.assertEqual(dashboard["capture_warning_count"], 1) + self.assertEqual(dashboard["captures"][0]["secret_warnings"], ["OpenAI API key"]) + self.assertIn("Redact capture warnings", dashboard["next_actions"][0]["label"]) + self.assertIn("accept-capture", dashboard["captures"][0]["commands"]["accept"]) + self.assertIn("Raw captures", html) + self.assertIn("redact-capture", html) + def test_cache_invalidation_sees_existing_page_edits(self): wiki = self.make_wiki() page = write_page( From 510a62b828d3b425affcb70dfde9fc8e2091b446 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:41:20 -0600 Subject: [PATCH 049/292] Filter web memory surfaces by project Add project query support to memory dashboard/profile/inbox APIs and pages, including project-filtered raw captures. --- CHANGELOG.md | 1 + README.md | 6 ++--- serve.py | 61 ++++++++++++++++++++++++++++------------- tests/test_serve.py | 66 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 946b0da..c3af699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link.py delete-capture` with explicit confirmation for removing saved raw captures without logging capture contents. - Added MCP `delete_capture` with explicit confirmation for removing saved raw captures. - Added raw capture visibility to `/memory` and `/api/memory-dashboard`, including accept/redact/delete commands and secret-warning counts. +- Added project filtering to `/memory`, `/profile`, `/api/memory-dashboard`, `/api/memory-profile`, and `/api/memory-inbox`. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/README.md b/README.md index 9d0d52b..a734989 100644 --- a/README.md +++ b/README.md @@ -416,9 +416,9 @@ Common endpoints: | Endpoint | Description | |----------|-------------| | `GET /api/pages` | All pages with title, type, tags, aliases, maturity, and TLDR. | -| `GET /api/memory-dashboard` | Read-only memory dashboard data, including saved raw captures and secret-warning counts. | -| `GET /api/memory-profile` | Counts and recent memories for the local memory profile. | -| `GET /api/memory-inbox` | Memories that need review or metadata cleanup. | +| `GET /api/memory-dashboard?project=` | Read-only memory dashboard data, including saved raw captures and secret-warning counts. | +| `GET /api/memory-profile?project=` | Counts and recent memories for the local memory profile. | +| `GET /api/memory-inbox?project=` | Memories that need review or metadata cleanup. | | `GET /api/explain-memory?memory=` | Provenance, lifecycle, graph links, review state, and recall readiness. | | `POST /api/propose-memories` | Returns memory proposals without writing pages. | | `GET /api/search?q=` | Ranked search by title, alias, tag, TLDR, and full text. | diff --git a/serve.py b/serve.py index aab341c..013c42e 100644 --- a/serve.py +++ b/serve.py @@ -19,6 +19,8 @@ memory_records as _core_memory_records, memory_review_issues as _core_memory_review_issues, memory_duplicate_candidates as _core_memory_duplicate_candidates, + memory_visible_for_project as _core_memory_visible_for_project, + normalize_project as _core_normalize_project, propose_memories_from_text as _core_propose_memories_from_text, ) from link_core.frontmatter import ( @@ -140,9 +142,18 @@ def _memory_review_issues(record: dict[str, object]) -> list[dict[str, str]]: return _core_memory_review_issues(record, review_command="review-memory") -def _memory_inbox(limit: int = 20, include_archived: bool = False) -> dict[str, object]: +def _project_visible_records(project: str | None = None) -> list[dict[str, object]]: + project_name = _core_normalize_project(project) + return [ + record + for record in _memory_records() + if _core_memory_visible_for_project(record, project_name) + ] + + +def _memory_inbox(limit: int = 20, include_archived: bool = False, project: str | None = None) -> dict[str, object]: return _core_memory_inbox( - _memory_records(), + _project_visible_records(project), limit=limit, include_archived=include_archived, review_command="review-memory", @@ -200,8 +211,8 @@ def _memory_explanation(identifier: str) -> dict[str, object]: ) -def _memory_profile(limit: int = 10) -> dict[str, object]: - return _core_memory_profile(_memory_records(), limit=limit, review_command="review-memory") +def _memory_profile(limit: int = 10, project: str | None = None) -> dict[str, object]: + return _core_memory_profile(_memory_records(), limit=limit, review_command="review-memory", project=project) def _memory_activity_key(record: dict[str, object]) -> tuple[str, str, str]: @@ -304,7 +315,8 @@ def _memory_dashboard_next_actions( return actions[:3] -def _capture_records(limit: int = 12) -> list[dict[str, object]]: +def _capture_records(limit: int = 12, project: str | None = None) -> list[dict[str, object]]: + project_name = _core_normalize_project(project) root = WIKI_DIR.parent capture_dir = RAW_DIR / "memory-captures" if not capture_dir.exists(): @@ -320,12 +332,15 @@ def _capture_records(limit: int = 12) -> list[dict[str, object]]: continue meta, body = _parse_frontmatter(text) title = str(meta.get("title") or path.stem) + capture_project = _core_normalize_project(str(meta.get("project") or "")) + if project_name and capture_project and capture_project != project_name: + continue warnings = _secret_value_warnings(text) rel = path.relative_to(root).as_posix() records.append({ "path": rel, "title": title, - "project": str(meta.get("project") or ""), + "project": capture_project, "date_captured": str(meta.get("date_captured") or ""), "size_bytes": stat.st_size, "secret_warnings": warnings, @@ -341,9 +356,10 @@ def _capture_records(limit: int = 12) -> list[dict[str, object]]: return records[:limit] -def _memory_dashboard(limit: int = 12) -> dict[str, object]: +def _memory_dashboard(limit: int = 12, project: str | None = None) -> dict[str, object]: limit = max(1, min(limit, 50)) - records = _memory_records() + project_name = _core_normalize_project(project) + records = _project_visible_records(project_name) active_records = [record for record in records if _is_active_memory(record)] archived_records = [ record for record in records @@ -359,11 +375,11 @@ def _memory_dashboard(limit: int = 12) -> dict[str, object]: reverse=True, ) archived = sorted(archived_records, key=_memory_activity_key, reverse=True) - inbox = _memory_inbox(limit=limit) + inbox = _memory_inbox(limit=limit, project=project_name) review_count = inbox["review_count"] updated_count = len(recent_updates) archived_count = len(archived_records) - captures = _capture_records(limit=limit) + captures = _capture_records(limit=limit, project=project_name) capture_warning_count = sum(1 for capture in captures if capture["warning_count"]) return { "memory_count": len(records), @@ -373,6 +389,7 @@ def _memory_dashboard(limit: int = 12) -> dict[str, object]: "updated_count": updated_count, "capture_count": len(captures), "capture_warning_count": capture_warning_count, + "project": project_name, "by_type": _count_values(records, "memory_type"), "by_scope": _count_values(records, "scope"), "counts_by_severity": inbox["counts_by_severity"], @@ -1109,8 +1126,8 @@ def _render_memory_next_actions(actions: list[dict[str, str]]) -> str: return f'
Next actions
    {items}
' -def _render_memory_dashboard(): - dashboard = _memory_dashboard(limit=8) +def _render_memory_dashboard(project: str | None = None): + dashboard = _memory_dashboard(limit=8, project=project) stats = ( f'
' f'
{dashboard["memory_count"]}memories
' @@ -1135,6 +1152,7 @@ def _render_memory_dashboard(): f'

Memory Dashboard

' f'
' f'

Read-only command center for what local agents can remember, what needs review, and what changed recently.

' + f'{"

Project: " + html.escape(str(dashboard["project"])) + "

" if dashboard["project"] else ""}' f'{stats}' f'{_render_memory_next_actions(dashboard["next_actions"])}' f'{counts}' @@ -1148,8 +1166,8 @@ def _render_memory_dashboard(): return _layout("Memory Dashboard", body) -def _render_profile(): - profile = _memory_profile(limit=12) +def _render_profile(project: str | None = None): + profile = _memory_profile(limit=12, project=project) memory_count = profile["memory_count"] active_count = profile["active_count"] stats = ( @@ -1186,6 +1204,7 @@ def section(title: str, records: list[dict[str, object]], empty: str = "none") - f'

Memory Profile

' f'
' f'

What Link currently remembers about the user, projects, decisions, and preferences.

' + f'{"

Project: " + html.escape(str(profile["project"])) + "

" if profile["project"] else ""}' f'{stats}' f'{counts_line("Types", profile["by_type"])}' f'{counts_line("Scopes", profile["by_scope"])}' @@ -2010,14 +2029,14 @@ def do_GET(self): elif path in ("/", ""): self._ok(_render_home()) elif path == "/memory": - self._ok(_render_memory_dashboard()) + self._ok(_render_memory_dashboard(project=query.get("project", [""])[0])) elif path == "/inbox": self._ok(_render_inbox()) elif path == "/explain-memory": identifier = query.get("memory", [""])[0].strip() or query.get("name", [""])[0].strip() self._ok(_render_explain_memory(identifier)) elif path == "/profile": - self._ok(_render_profile()) + self._ok(_render_profile(project=query.get("project", [""])[0])) elif path == "/all": self._ok(_render_all()) elif path == "/graph": @@ -2045,20 +2064,24 @@ def do_GET(self): if error: self._json({"error": error}, status=400) else: - self._json(_memory_profile(limit=limit)) + self._json(_memory_profile(limit=limit, project=query.get("project", [""])[0])) elif path == "/api/memory-dashboard": limit, error = _parse_search_limit(query.get("limit", ["12"])[0]) if error: self._json({"error": error}, status=400) else: - self._json(_memory_dashboard(limit=limit)) + self._json(_memory_dashboard(limit=limit, project=query.get("project", [""])[0])) elif path == "/api/memory-inbox": limit, error = _parse_search_limit(query.get("limit", ["20"])[0]) if error: self._json({"error": error}, status=400) else: include_archived = query.get("include_archived", ["false"])[0].lower() in {"1", "true", "yes"} - self._json(_memory_inbox(limit=limit, include_archived=include_archived)) + self._json(_memory_inbox( + limit=limit, + include_archived=include_archived, + project=query.get("project", [""])[0], + )) elif path == "/api/propose-memories": self._json({"error": "use POST with JSON body: {\"text\": \"...\"}"}, status=405) elif path == "/api/explain-memory": diff --git a/tests/test_serve.py b/tests/test_serve.py index d2264f8..0e021c9 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -268,6 +268,72 @@ def test_memory_dashboard_surfaces_raw_captures_and_secret_warnings(self): self.assertIn("Raw captures", html) self.assertIn("redact-capture", html) + def test_memory_dashboard_filters_project_memory_and_captures(self): + wiki = self.make_wiki() + write_page( + wiki, + "memories/global-style.md", + ( + "---\n" + "type: memory\n" + "title: \"Global style\"\n" + "memory_type: preference\n" + "scope: user\n" + "status: active\n" + "date_captured: \"2026-05-05T00:00:00Z\"\n" + "source: \"unit test\"\n" + "review_status: reviewed\n" + "---\n\n" + "# Global style\n\n" + "> **TLDR:** User prefers concise updates.\n" + ), + ) + for project in ("alpha", "beta"): + write_page( + wiki, + f"memories/{project}-imports.md", + ( + "---\n" + "type: memory\n" + f"title: \"{project.title()} imports\"\n" + "memory_type: project\n" + "scope: project\n" + f"project: \"{project}\"\n" + "status: active\n" + "date_captured: \"2026-05-05T00:00:00Z\"\n" + "source: \"unit test\"\n" + "review_status: reviewed\n" + "---\n\n" + f"# {project.title()} imports\n\n" + f"> **TLDR:** {project.title()} has project-specific imports.\n" + ), + ) + capture_dir = wiki.parent / "raw" / "memory-captures" + capture_dir.mkdir(parents=True) + for project in ("alpha", "beta"): + (capture_dir / f"{project}.md").write_text( + "---\n" + f"title: \"{project.title()} capture\"\n" + "source_type: conversation\n" + "date_captured: \"2026-05-05T00:00:00Z\"\n" + f"project: \"{project}\"\n" + "---\n\n" + "# Capture\n\n## Notes\n\nMemory capture.\n", + encoding="utf-8", + ) + + dashboard = serve._memory_dashboard(limit=8, project="alpha") + status, payload = run_handler("GET", "/api/memory-dashboard?project=alpha") + html = serve._render_memory_dashboard(project="alpha") + + self.assertEqual(status, 200) + self.assertEqual(dashboard["project"], "alpha") + self.assertEqual(payload["project"], "alpha") + self.assertEqual({record["name"] for record in dashboard["active"]}, {"global-style", "alpha-imports"}) + self.assertEqual([capture["project"] for capture in dashboard["captures"]], ["alpha"]) + self.assertIn("Project: alpha", html) + self.assertNotIn("Beta imports", html) + def test_cache_invalidation_sees_existing_page_edits(self): wiki = self.make_wiki() page = write_page( From 212db8c02067b3c3f7a0a0e4155d3825fd820212 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:46:39 -0600 Subject: [PATCH 050/292] Add CLI capture inbox List saved raw captures with project filtering, secret-warning labels, and accept/redact/delete commands. Redact secret-looking values from capture snippets before returning CLI or web payloads. --- CHANGELOG.md | 1 + LINK.md | 1 + README.md | 7 ++ .../_shared/link-instructions-project.md | 1 + link.py | 104 ++++++++++++++++++ serve.py | 6 +- tests/test_link_cli.py | 52 +++++++++ tests/test_serve.py | 3 + 8 files changed, 174 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3af699..1cd83d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added MCP `redact_capture` so agents can redact saved raw captures after user approval. - Added `link.py delete-capture` with explicit confirmation for removing saved raw captures without logging capture contents. - Added MCP `delete_capture` with explicit confirmation for removing saved raw captures. +- Added `link.py capture-inbox` to list saved raw captures, secret warnings, and accept/redact/delete commands. - Added raw capture visibility to `/memory` and `/api/memory-dashboard`, including accept/redact/delete commands and secret-warning counts. - Added project filtering to `/memory`, `/profile`, `/api/memory-dashboard`, `/api/memory-profile`, and `/api/memory-inbox`. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. diff --git a/LINK.md b/LINK.md index 159ff4f..8f479ae 100644 --- a/LINK.md +++ b/LINK.md @@ -290,6 +290,7 @@ Rules: - For `scope: project`, include a project key when you know it. `link.py` infers this from repo-local installs; otherwise pass `--project ` or MCP `project`. - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. +- Use `python3 link.py capture-inbox .` to review saved raw captures, secret warnings, and the exact accept/redact/delete commands before changing capture state. - When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index ` or MCP `accept_capture`. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. - If capture results report `secret_warnings`, ask the human whether to redact the raw capture. Use `python3 link.py redact-capture "" .` or MCP `redact_capture`; it replaces secret-looking values and logs labels/counts only. - If the human asks to remove a raw capture, run `python3 link.py delete-capture "" . --confirm` or MCP `delete_capture` with `confirm: true`. Never delete captures without explicit confirmation. diff --git a/README.md b/README.md index a734989..d79c355 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,12 @@ This stores the note under `raw/memory-captures/`, logs the capture locally, and returns memory proposals for human approval. Capture results warn on secret-looking pasted values so you can redact the local raw note. +Review saved captures before approving or deleting them: + +```bash +python3 ~/link/link.py capture-inbox ~/link --project link +``` + Approve one proposal when it is right: ```bash @@ -435,6 +441,7 @@ Common endpoints: | `python3 link.py remember "text" [--project slug]` | Save a local agent memory; strong duplicates and likely conflicts are refused unless explicitly allowed. | | `python3 link.py propose-memories [--project slug]` | Propose durable memories from notes without writing them. | | `python3 link.py capture-session [--project slug]` | Save chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates. | +| `python3 link.py capture-inbox [--project slug]` | List saved raw captures with secret warnings and accept/redact/delete commands. | | `python3 link.py accept-capture [--index N]` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | | `python3 link.py redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | | `python3 link.py delete-capture --confirm` | Delete a saved raw capture after explicit confirmation. | diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index aa6e5bd..40e3762 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -5,6 +5,7 @@ This project has a Link wiki. Raw sources live in `raw/`, compiled wiki pages in When starting project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `python3 link.py brief "" .`. Project installs infer the current repo as the memory project key, so project-scoped memories stay separate from other repos while broad user memories still apply. For long session notes, use `python3 link.py capture-session "" .` to store a local raw capture and produce memory proposals without writing durable memories. +Use `python3 link.py capture-inbox .` to review saved captures, warnings, and next-step commands. When the human approves a proposal from a capture, use `python3 link.py accept-capture "" . --index `. If a capture reports secret warnings, ask before running `python3 link.py redact-capture "" .`. Only delete a raw capture after explicit confirmation: `python3 link.py delete-capture "" . --confirm`. diff --git a/link.py b/link.py index 8d0bef4..44a693e 100644 --- a/link.py +++ b/link.py @@ -7,6 +7,7 @@ python link.py ingest-status [target] python link.py remember "memory text" [target] python link.py propose-memories [target] + python link.py capture-inbox [target] python link.py update-memory "new memory text" [target] python link.py brief ["task or question"] [target] python link.py recall "query" [target] @@ -97,6 +98,7 @@ memory_inbox as _core_memory_inbox, memory_profile as _core_memory_profile, memory_records as _core_memory_records, + normalize_project as _core_normalize_project, memory_review_issues as _core_memory_review_issues, propose_memories_from_text as _core_propose_memories_from_text, recall_memories as _core_recall_memories, @@ -1783,6 +1785,95 @@ def _capture_notes_from_markdown(text: str) -> tuple[dict[str, object], str]: return meta, notes +def _capture_records(target: Path, limit: int = 20, project: str | None = None) -> list[dict[str, object]]: + root = _resolve_link_root(target) + capture_dir = root / "raw" / "memory-captures" + if not capture_dir.exists(): + return [] + project_name = _core_normalize_project(project) + records: list[dict[str, object]] = [] + for path in sorted(capture_dir.rglob("*.md")): + if path.name.startswith("."): + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + stat = path.stat() + except OSError: + continue + meta, notes = _capture_notes_from_markdown(text) + rel = path.relative_to(root).as_posix() + warnings = _secret_value_warnings(text) + safe_notes, _, _ = _redact_secret_values(notes) + capture_project = _core_normalize_project(str(meta.get("project") or "")) + if project_name and capture_project and capture_project != project_name: + continue + records.append({ + "path": rel, + "title": str(meta.get("title") or path.stem), + "project": capture_project, + "date_captured": str(meta.get("date_captured") or ""), + "size_bytes": stat.st_size, + "secret_warnings": warnings, + "warning_count": len(warnings), + "snippet": re.sub(r"\s+", " ", safe_notes).strip()[:180], + "commands": { + "accept": f'python3 link.py accept-capture "{rel}" . --index 1', + "redact": f'python3 link.py redact-capture "{rel}" .', + "delete": f'python3 link.py delete-capture "{rel}" . --confirm', + }, + }) + records.sort(key=lambda item: (str(item["date_captured"]), str(item["path"])), reverse=True) + return records[:max(1, min(limit, 50))] + + +def capture_inbox( + target: Path, + limit: int = 20, + project: str | None = None, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + root = _resolve_link_root(target) + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + project_name = _core_normalize_project(project) + captures = _capture_records(root, limit=limit, project=project_name) + warning_count = sum(1 for capture in captures if capture["warning_count"]) + payload = { + "count": len(captures), + "warning_count": warning_count, + "project": project_name, + "captures": captures, + } + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + + print("Raw capture inbox") + if project_name: + print(f"Project: {project_name}") + print(f"{len(captures)} capture{'s' if len(captures) != 1 else ''} · {warning_count} with secret-looking warnings") + if not captures: + print("") + print("No saved raw captures.") + return 0 + for index, capture in enumerate(captures, start=1): + print("") + print(f"{index}. {capture['title']}") + print(f" Path: {capture['path']}") + if capture["project"]: + print(f" Project: {capture['project']}") + if capture["secret_warnings"]: + print(" Secret-looking values: " + ", ".join(capture["secret_warnings"])) + print(f" Accept: {capture['commands']['accept']}") + if capture["secret_warnings"]: + print(f" Redact: {capture['commands']['redact']}") + print(f" Delete: {capture['commands']['delete']}") + return 0 + + def accept_capture( target: Path, capture: str, @@ -2639,6 +2730,12 @@ def main(argv: list[str] | None = None) -> int: capture_cmd.add_argument("--project", default=None, help="project key for proposal checks") capture_cmd.add_argument("--json", action="store_true", help="print machine-readable capture details") + capture_inbox_cmd = sub.add_parser("capture-inbox", help="list saved raw session captures") + capture_inbox_cmd.add_argument("target", nargs="?", default=".") + capture_inbox_cmd.add_argument("--limit", type=int, default=20) + capture_inbox_cmd.add_argument("--project", default=None, help="include global captures plus this project") + capture_inbox_cmd.add_argument("--json", action="store_true", help="print machine-readable capture inbox") + accept_capture_cmd = sub.add_parser("accept-capture", help="accept one proposal from a raw session capture") accept_capture_cmd.add_argument("capture", help="raw capture path or filename") accept_capture_cmd.add_argument("target", nargs="?", default=".") @@ -2769,6 +2866,13 @@ def main(argv: list[str] | None = None) -> int: project=args.project, json_output=args.json, ) + if args.command == "capture-inbox": + return capture_inbox( + Path(args.target), + limit=args.limit, + project=args.project, + json_output=args.json, + ) if args.command == "accept-capture": return accept_capture( Path(args.target), diff --git a/serve.py b/serve.py index 013c42e..adfdc04 100644 --- a/serve.py +++ b/serve.py @@ -27,6 +27,7 @@ parse_frontmatter as _parse_frontmatter, ) from link_core.security import ( + redact_secret_values as _redact_secret_values, secret_value_warnings as _secret_value_warnings, ) from link_core.wiki import ( @@ -331,11 +332,14 @@ def _capture_records(limit: int = 12, project: str | None = None) -> list[dict[s except OSError: continue meta, body = _parse_frontmatter(text) + notes_match = re.search(r"^## Notes\s*(.*?)(?=^## |\Z)", body, flags=re.MULTILINE | re.DOTALL) + notes = notes_match.group(1).strip() if notes_match else body.strip() title = str(meta.get("title") or path.stem) capture_project = _core_normalize_project(str(meta.get("project") or "")) if project_name and capture_project and capture_project != project_name: continue warnings = _secret_value_warnings(text) + safe_notes, _, _ = _redact_secret_values(notes) rel = path.relative_to(root).as_posix() records.append({ "path": rel, @@ -345,7 +349,7 @@ def _capture_records(limit: int = 12, project: str | None = None) -> list[dict[s "size_bytes": stat.st_size, "secret_warnings": warnings, "warning_count": len(warnings), - "snippet": re.sub(r"\s+", " ", body).strip()[:180], + "snippet": re.sub(r"\s+", " ", safe_notes).strip()[:180], "commands": { "accept": f'python3 link.py accept-capture "{rel}" . --index 1', "redact": f'python3 link.py redact-capture "{rel}" .', diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 9331100..6261835 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -491,6 +491,58 @@ def test_capture_session_writes_raw_note_and_proposes_only(self): self.assertEqual(len(after_memories), len(before_memories)) self.assertIn("capture-session", log_text) + def test_capture_inbox_lists_captures_without_secret_values(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + fake_key = "sk-" + ("E" * 24) + + alpha_out = StringIO() + with redirect_stdout(alpha_out): + alpha_code = link_cli.capture_session( + target, + f"Remember that Alpha project captures need review. Test key {fake_key}", + title="Alpha capture", + project="alpha", + json_output=True, + ) + beta_out = StringIO() + with redirect_stdout(beta_out): + beta_code = link_cli.capture_session( + target, + "Remember that Beta project captures stay separate.", + title="Beta capture", + project="beta", + json_output=True, + ) + + inbox_out = StringIO() + with redirect_stdout(inbox_out): + inbox_code = link_cli.capture_inbox(target, project="alpha", json_output=True) + inbox = json.loads(inbox_out.getvalue()) + + text_out = StringIO() + with redirect_stdout(text_out): + text_code = link_cli.capture_inbox(target, project="alpha") + text = text_out.getvalue() + + self.assertEqual(alpha_code, 0) + self.assertEqual(beta_code, 0) + self.assertEqual(inbox_code, 0) + self.assertEqual(text_code, 0) + self.assertEqual(inbox["project"], "alpha") + self.assertEqual(inbox["count"], 1) + self.assertEqual(inbox["warning_count"], 1) + self.assertEqual(inbox["captures"][0]["project"], "alpha") + self.assertEqual(inbox["captures"][0]["secret_warnings"], ["OpenAI API key"]) + self.assertIn("[redacted-secret]", inbox["captures"][0]["snippet"]) + self.assertNotIn(fake_key, inbox_out.getvalue()) + self.assertIn("accept-capture", inbox["captures"][0]["commands"]["accept"]) + self.assertIn("redact-capture", text) + self.assertIn("delete-capture", text) + self.assertNotIn("Beta capture", inbox_out.getvalue()) + self.assertNotIn(fake_key, text) + def test_accept_capture_writes_approved_proposal(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" diff --git a/tests/test_serve.py b/tests/test_serve.py index 0e021c9..429716a 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -263,10 +263,13 @@ def test_memory_dashboard_surfaces_raw_captures_and_secret_warnings(self): self.assertEqual(dashboard["capture_count"], 1) self.assertEqual(dashboard["capture_warning_count"], 1) self.assertEqual(dashboard["captures"][0]["secret_warnings"], ["OpenAI API key"]) + self.assertIn("[redacted-secret]", dashboard["captures"][0]["snippet"]) + self.assertNotIn(fake_key, dashboard["captures"][0]["snippet"]) self.assertIn("Redact capture warnings", dashboard["next_actions"][0]["label"]) self.assertIn("accept-capture", dashboard["captures"][0]["commands"]["accept"]) self.assertIn("Raw captures", html) self.assertIn("redact-capture", html) + self.assertNotIn(fake_key, html) def test_memory_dashboard_filters_project_memory_and_captures(self): wiki = self.make_wiki() From 6d7baf2b57af06f1e3d49be1afd926e7d6e130aa Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:48:51 -0600 Subject: [PATCH 051/292] Add MCP capture inbox Expose saved raw capture review over MCP with project filtering, secret-warning labels, redacted snippets, and next-action tool hints. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 3 +- .../_shared/link-instructions-project.md | 2 +- mcp_package/link_mcp/server.py | 67 ++++++++++++++++++- tests/test_mcp_contract.py | 30 +++++++++ 6 files changed, 101 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd83d6..543626a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link.py delete-capture` with explicit confirmation for removing saved raw captures without logging capture contents. - Added MCP `delete_capture` with explicit confirmation for removing saved raw captures. - Added `link.py capture-inbox` to list saved raw captures, secret warnings, and accept/redact/delete commands. +- Added MCP `capture_inbox` to review saved raw captures with redacted snippets before accepting, redacting, or deleting them. - Added raw capture visibility to `/memory` and `/api/memory-dashboard`, including accept/redact/delete commands and secret-warning counts. - Added project filtering to `/memory`, `/profile`, `/api/memory-dashboard`, `/api/memory-profile`, and `/api/memory-inbox`. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. diff --git a/LINK.md b/LINK.md index 8f479ae..7c6b018 100644 --- a/LINK.md +++ b/LINK.md @@ -290,7 +290,7 @@ Rules: - For `scope: project`, include a project key when you know it. `link.py` infers this from repo-local installs; otherwise pass `--project ` or MCP `project`. - At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. -- Use `python3 link.py capture-inbox .` to review saved raw captures, secret warnings, and the exact accept/redact/delete commands before changing capture state. +- Use `python3 link.py capture-inbox .` or MCP `capture_inbox` to review saved raw captures, secret warnings, and the exact accept/redact/delete commands before changing capture state. - When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index ` or MCP `accept_capture`. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. - If capture results report `secret_warnings`, ask the human whether to redact the raw capture. Use `python3 link.py redact-capture "" .` or MCP `redact_capture`; it replaces secret-looking values and logs labels/counts only. - If the human asks to remove a raw capture, run `python3 link.py delete-capture "" . --confirm` or MCP `delete_capture` with `confirm: true`. Never delete captures without explicit confirmation. diff --git a/README.md b/README.md index d79c355..a0ab202 100644 --- a/README.md +++ b/README.md @@ -390,13 +390,14 @@ Most agents should start with: | `remember_memory` | The user explicitly approves saving a durable memory. | | `propose_memories` | You want memory candidates from chat/session notes without writing. | | `capture_session` | You want to save long session notes locally before approving memory writes; results include secret-looking content warnings. | +| `capture_inbox` | You want to review saved raw captures, redacted snippets, warnings, and next actions. | | `accept_capture` | The user approves one proposal from a saved raw capture. | | `redact_capture` | The user approves redacting secret-looking values from a saved raw capture. | | `delete_capture` | The user explicitly confirms deleting a saved raw capture. | Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, -`propose_memories`, `capture_session`, `accept_capture`, `redact_capture`, `delete_capture`, +`propose_memories`, `capture_session`, `capture_inbox`, `accept_capture`, `redact_capture`, `delete_capture`, `update_memory`, `archive_memory`, `restore_memory`, `get_context`, `get_pages`, `get_backlinks`, `get_graph`, `rebuild_backlinks`. diff --git a/integrations/_shared/link-instructions-project.md b/integrations/_shared/link-instructions-project.md index 40e3762..196de55 100644 --- a/integrations/_shared/link-instructions-project.md +++ b/integrations/_shared/link-instructions-project.md @@ -5,7 +5,7 @@ This project has a Link wiki. Raw sources live in `raw/`, compiled wiki pages in When starting project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `python3 link.py brief "" .`. Project installs infer the current repo as the memory project key, so project-scoped memories stay separate from other repos while broad user memories still apply. For long session notes, use `python3 link.py capture-session "" .` to store a local raw capture and produce memory proposals without writing durable memories. -Use `python3 link.py capture-inbox .` to review saved captures, warnings, and next-step commands. +Use MCP `capture_inbox` when available, or `python3 link.py capture-inbox .`, to review saved captures, warnings, and next-step commands. When the human approves a proposal from a capture, use `python3 link.py accept-capture "" . --index `. If a capture reports secret warnings, ask before running `python3 link.py redact-capture "" .`. Only delete a raw capture after explicit confirmation: `python3 link.py delete-capture "" . --confirm`. diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 18cce47..0acce4a 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -59,7 +59,8 @@ "what Link remembers, memory_inbox to find memories needing review, and " "explain_memory to audit why a memory exists. Use capture_session for " "long chat or session notes that should be stored locally before memory " - "approval; use propose_memories when no raw capture is needed. Use search_wiki to find " + "approval, and capture_inbox to review saved captures before accepting, " + "redacting, or deleting them; use propose_memories when no raw capture is needed. Use search_wiki to find " "general pages and get_context to retrieve a topic with its full graph " "neighborhood. Only call remember_memory when the user explicitly asks " "you to remember something; if it returns duplicate candidates, use " @@ -84,6 +85,7 @@ memory_inbox as _core_memory_inbox, memory_profile as _core_memory_profile, memory_records as _core_memory_records, + normalize_project as _core_normalize_project, memory_review_issues as _core_memory_review_issues, propose_memories_from_text as _core_propose_memories_from_text, recall_memories as _core_recall_memories, @@ -393,6 +395,58 @@ def _capture_notes_from_markdown(text: str) -> tuple[dict[str, object], str]: return meta, notes +def _capture_records(limit: int = 20, project: str = "") -> list[dict[str, object]]: + root = WIKI_DIR.parent + capture_dir = root / "raw" / "memory-captures" + if not capture_dir.exists(): + return [] + project_name = _core_normalize_project(project) + records: list[dict[str, object]] = [] + for path in sorted(capture_dir.rglob("*.md")): + if path.name.startswith("."): + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + stat = path.stat() + except OSError: + continue + meta, notes = _capture_notes_from_markdown(text) + capture_project = _core_normalize_project(str(meta.get("project") or "")) + if project_name and capture_project and capture_project != project_name: + continue + rel = path.relative_to(root).as_posix() + warnings = _secret_value_warnings(text) + safe_notes, _, _ = _redact_secret_values(notes) + records.append({ + "path": rel, + "title": str(meta.get("title") or path.stem), + "project": capture_project, + "date_captured": str(meta.get("date_captured") or ""), + "size_bytes": stat.st_size, + "secret_warnings": warnings, + "warning_count": len(warnings), + "snippet": re.sub(r"\s+", " ", safe_notes).strip()[:180], + "commands": { + "accept": f'accept_capture(capture="{rel}", index=1)', + "redact": f'redact_capture(capture="{rel}")', + "delete": f'delete_capture(capture="{rel}", confirm=true)', + }, + }) + records.sort(key=lambda item: (str(item["date_captured"]), str(item["path"])), reverse=True) + return records[:max(1, min(limit, 50))] + + +def _capture_inbox(limit: int = 20, project: str = "") -> dict[str, object]: + project_name = _core_normalize_project(project) + captures = _capture_records(limit=limit, project=project_name) + return { + "count": len(captures), + "warning_count": sum(1 for capture in captures if capture["warning_count"]), + "project": project_name, + "captures": captures, + } + + def _accept_capture( capture: str, index: int = 1, @@ -749,6 +803,17 @@ def capture_session(text: str, title: str = "", source: str = "mcp", limit: int return json.dumps(result, ensure_ascii=False) +@mcp.tool() +def capture_inbox(limit: int = 20, project: str = "") -> str: + """List saved raw session captures without changing them. + + Returns saved captures, secret-warning labels, redacted snippets, and the + next MCP tool calls for accepting, redacting, or deleting a capture. + """ + limit = _parse_limit(limit, default=20, max_limit=50) + return json.dumps(_capture_inbox(limit=limit, project=project), ensure_ascii=False) + + @mcp.tool() def accept_capture( capture: str, diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index bc79ec8..9513f60 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -255,6 +255,36 @@ def test_capture_session_contract(self): self.assertEqual(len(after_memories), len(before_memories)) self.assertIn("capture-session", log_text) + def test_capture_inbox_contract(self): + fake_key = "sk-" + ("B" * 24) + alpha = json.loads(self.server.capture_session( + f"Remember that MCP Alpha captures need review. Test key {fake_key}", + title="MCP Alpha capture", + project="alpha", + )) + beta = json.loads(self.server.capture_session( + "Remember that MCP Beta captures stay separate.", + title="MCP Beta capture", + project="beta", + )) + + raw_payload = self.server.capture_inbox(project="alpha") + payload = json.loads(raw_payload) + + self.assertTrue(alpha["captured"]) + self.assertTrue(beta["captured"]) + self.assertEqual(payload["project"], "alpha") + self.assertEqual(payload["count"], 1) + self.assertEqual(payload["warning_count"], 1) + self.assertEqual(payload["captures"][0]["project"], "alpha") + self.assertEqual(payload["captures"][0]["secret_warnings"], ["OpenAI API key"]) + self.assertIn("[redacted-secret]", payload["captures"][0]["snippet"]) + self.assertIn("accept_capture", payload["captures"][0]["commands"]["accept"]) + self.assertIn("redact_capture", payload["captures"][0]["commands"]["redact"]) + self.assertIn("delete_capture", payload["captures"][0]["commands"]["delete"]) + self.assertNotIn(fake_key, raw_payload) + self.assertNotIn("MCP Beta capture", raw_payload) + def test_accept_capture_contract(self): capture = json.loads(self.server.capture_session( "We decided to keep MCP capture approval local and explicit.", From 558c635c41c0c15d1c3a4e78f75f7e5c595bb8b8 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:53:10 -0600 Subject: [PATCH 052/292] Filter memory inbox by project Add shared project filtering for memory inbox payloads and expose --project / project arguments through CLI, MCP, and web API paths. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 2 +- link.py | 15 ++++++++++-- mcp_package/link_core/memory.py | 5 ++++ mcp_package/link_mcp/server.py | 10 +++++--- serve.py | 1 + tests/test_link_cli.py | 43 +++++++++++++++++++++++++++++++++ tests/test_mcp_contract.py | 26 ++++++++++++++++++++ tests/test_memory_core.py | 30 +++++++++++++++++++++++ 10 files changed, 127 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 543626a..b6fb40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added MCP `capture_inbox` to review saved raw captures with redacted snippets before accepting, redacting, or deleting them. - Added raw capture visibility to `/memory` and `/api/memory-dashboard`, including accept/redact/delete commands and secret-warning counts. - Added project filtering to `/memory`, `/profile`, `/api/memory-dashboard`, `/api/memory-profile`, and `/api/memory-inbox`. +- Added project filtering to CLI and MCP memory inbox workflows. - Added read-only web Memory Dashboard at `/memory` and `/api/memory-dashboard` for active memories, review queue, recent updates, archived memories, and next-action commands. - Added secure proposal-only HTTP endpoint `POST /api/propose-memories`; memory write operations remain CLI/MCP-only. - Added a graph node inspector so moving nodes no longer accidentally opens pages; double-click or Open page still navigates. diff --git a/LINK.md b/LINK.md index 7c6b018..08c79a5 100644 --- a/LINK.md +++ b/LINK.md @@ -296,7 +296,7 @@ Rules: - If the human asks to remove a raw capture, run `python3 link.py delete-capture "" . --confirm` or MCP `delete_capture` with `confirm: true`. Never delete captures without explicit confirmation. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. -- Run `python3 link.py memory-inbox .` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. +- Run `python3 link.py memory-inbox .` or MCP `memory_inbox` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. Pass `--project ` or MCP `project` when reviewing a specific project. - If `remember` reports a duplicate candidate, inspect it with `python3 link.py explain-memory "" .` and merge new information with `python3 link.py update-memory "" "new detail" .` instead of creating another one. Use `--allow-duplicate` only when the human confirms it should be separate. - If `remember`, `update-memory`, or `propose-memories` reports conflict candidates, stop and ask the human whether the older memory should be updated, archived, or allowed to coexist. Use `--allow-conflict` only when the human confirms both memories are true in different contexts. - After updating a memory, review it again with the human because `update-memory` resets `review_status` to `pending`. diff --git a/README.md b/README.md index a0ab202..537b986 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,7 @@ Common endpoints: | `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | | `python3 link.py recall "query" [--project slug]` | Search local agent memories. | | `python3 link.py profile [--project slug]` | Show what Link remembers by type, scope, status, and recency. | -| `python3 link.py memory-inbox ` | Show memories that need review or stronger metadata with next-step commands. | +| `python3 link.py memory-inbox [--project slug]` | Show memories that need review or stronger metadata with next-step commands. | | `python3 link.py review-memory ` | Mark a confirmed memory as reviewed. | | `python3 link.py explain-memory ` | Explain provenance, lifecycle, graph links, review issues, and recall readiness. | | `python3 link.py update-memory "text" [--project slug]` | Merge new text into an existing memory, blocking likely conflicts with other active memories by default. | diff --git a/link.py b/link.py index 44a693e..1a697c6 100644 --- a/link.py +++ b/link.py @@ -734,12 +734,18 @@ def _memory_review_issues(record: dict[str, object]) -> list[dict[str, str]]: return _core_memory_review_issues(record, review_command="review-memory") -def _memory_inbox(wiki_dir: Path, limit: int = 20, include_archived: bool = False) -> dict[str, object]: +def _memory_inbox( + wiki_dir: Path, + limit: int = 20, + include_archived: bool = False, + project: str | None = None, +) -> dict[str, object]: return _core_memory_inbox( _memory_records(wiki_dir), limit=limit, include_archived=include_archived, review_command="review-memory", + project=project, ) @@ -2247,6 +2253,7 @@ def memory_inbox( target: Path, limit: int = 20, include_archived: bool = False, + project: str | None = None, json_output: bool = False, ) -> int: target = target.expanduser().resolve() @@ -2254,13 +2261,15 @@ def memory_inbox( if not wiki_dir.exists(): print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) return 1 - inbox = _memory_inbox(wiki_dir, limit=limit, include_archived=include_archived) + inbox = _memory_inbox(wiki_dir, limit=limit, include_archived=include_archived, project=project) if json_output: print(json.dumps(inbox, indent=2)) return 0 print(f"Link memory inbox: {target}") + if inbox.get("project"): + print(f"Project: {inbox['project']}") if include_archived: print("Including archived memories") print("") @@ -2806,6 +2815,7 @@ def main(argv: list[str] | None = None) -> int: inbox_cmd.add_argument("target", nargs="?", default=".") inbox_cmd.add_argument("--limit", type=int, default=20) inbox_cmd.add_argument("--include-archived", action="store_true", help="include archived memories") + inbox_cmd.add_argument("--project", default=None, help="include user/global memories plus this project's memories") inbox_cmd.add_argument("--json", action="store_true", help="print machine-readable inbox") review_cmd = sub.add_parser("review-memory", help="mark a memory as reviewed") @@ -2933,6 +2943,7 @@ def main(argv: list[str] | None = None) -> int: Path(args.target), limit=args.limit, include_archived=args.include_archived, + project=args.project, json_output=args.json, ) if args.command == "review-memory": diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 2623cb9..7832ed4 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -1089,11 +1089,15 @@ def memory_inbox( limit: int = 20, include_archived: bool = False, review_command: str = "review-memory", + project: str | None = None, ) -> dict[str, object]: limit = max(1, min(limit, 50)) + project_name = normalize_project(project) severity_rank = {"high": 0, "medium": 1, "low": 2} items: list[dict[str, object]] = [] for record in records: + if not memory_visible_for_project(record, project_name): + continue if not include_archived and str(record.get("status") or "").lower() == "archived": continue issues = memory_review_issues(record, review_command=review_command) @@ -1123,6 +1127,7 @@ def memory_inbox( "review_count": len(items), "counts_by_severity": counts_by_severity, "include_archived": include_archived, + "project": project_name, "next_actions": [ item["primary_action"] for item in items[:limit] diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 0acce4a..3844cd4 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -183,12 +183,13 @@ def _memory_review_issues(record: dict[str, object]) -> list[dict[str, str]]: return _core_memory_review_issues(record, review_command="review_memory") -def _memory_inbox(limit: int = 20, include_archived: bool = False) -> dict[str, object]: +def _memory_inbox(limit: int = 20, include_archived: bool = False, project: str = "") -> dict[str, object]: return _core_memory_inbox( _memory_records(), limit=limit, include_archived=include_archived, review_command="review_memory", + project=project, ) @@ -889,15 +890,16 @@ def memory_profile(limit: int = 10, project: str = "") -> str: @mcp.tool() -def memory_inbox(limit: int = 20, include_archived: bool = False) -> str: +def memory_inbox(limit: int = 20, include_archived: bool = False, project: str = "") -> str: """List memories that need user review. Use this to surface pending, stale, invalid, or underspecified memories for human confirmation. Archived memories are excluded unless include_archived - is true. + is true. Pass project to include broad user/global memory plus that + project's scoped memories while excluding other explicit projects. """ limit = _parse_limit(limit, default=20) - return json.dumps(_memory_inbox(limit=limit, include_archived=include_archived), ensure_ascii=False) + return json.dumps(_memory_inbox(limit=limit, include_archived=include_archived, project=project), ensure_ascii=False) @mcp.tool() diff --git a/serve.py b/serve.py index adfdc04..388505e 100644 --- a/serve.py +++ b/serve.py @@ -158,6 +158,7 @@ def _memory_inbox(limit: int = 20, include_archived: bool = False, project: str limit=limit, include_archived=include_archived, review_command="review-memory", + project=project, ) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 6261835..05191ed 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -699,6 +699,49 @@ def test_memory_inbox_and_review_memory(self): self.assertEqual(clear_code, 0) self.assertEqual(clear["review_count"], 0) + def test_memory_inbox_filters_by_project(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + with redirect_stdout(StringIO()): + link_cli.review_memory(target, "prefer-local-personal-memory", json_output=True) + alpha_code = link_cli.remember( + target, + "Alpha project stores deployment context in Link.", + title="Alpha deployment context", + memory_type="project", + scope="project", + project="alpha", + json_output=True, + ) + beta_code = link_cli.remember( + target, + "Beta project stores design context in Link.", + title="Beta design context", + memory_type="project", + scope="project", + project="beta", + json_output=True, + ) + + inbox_out = StringIO() + with redirect_stdout(inbox_out): + inbox_code = link_cli.memory_inbox(target, project="alpha", json_output=True) + inbox = json.loads(inbox_out.getvalue()) + + text_out = StringIO() + with redirect_stdout(text_out): + text_code = link_cli.memory_inbox(target, project="alpha") + + self.assertEqual(alpha_code, 0) + self.assertEqual(beta_code, 0) + self.assertEqual(inbox_code, 0) + self.assertEqual(text_code, 0) + self.assertEqual(inbox["project"], "alpha") + self.assertEqual([item["project"] for item in inbox["items"]], ["alpha"]) + self.assertIn("Project: alpha", text_out.getvalue()) + self.assertNotIn("Beta design context", inbox_out.getvalue()) + def test_explain_memory_reports_trust_state_and_graph(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 9513f60..54b07e5 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -360,6 +360,32 @@ def test_memory_inbox_and_review_memory_contract(self): self.assertEqual(reviewed["remaining_issue_count"], 0) self.assertEqual(clear["review_count"], 0) + def test_memory_inbox_project_filter_contract(self): + self.server.review_memory("prefer-local-personal-memory") + alpha = json.loads(self.server.remember_memory( + "Alpha project stores deployment context in Link.", + title="Alpha deployment context", + memory_type="project", + scope="project", + project="alpha", + )) + beta = json.loads(self.server.remember_memory( + "Beta project stores design context in Link.", + title="Beta design context", + memory_type="project", + scope="project", + project="beta", + )) + + raw_payload = self.server.memory_inbox(project="alpha") + inbox = json.loads(raw_payload) + + self.assertTrue(alpha["created"]) + self.assertTrue(beta["created"]) + self.assertEqual(inbox["project"], "alpha") + self.assertEqual([item["project"] for item in inbox["items"]], ["alpha"]) + self.assertNotIn("Beta design context", raw_payload) + def test_explain_memory_contract(self): payload = json.loads(self.server.explain_memory("prefer-local-personal-memory")) diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 8f13afe..7cb0ab1 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -117,6 +117,36 @@ def test_memory_inbox_returns_action_plan(self): self.assertEqual(inbox["next_actions"][0]["kind"], "review") self.assertIn("actions", item) + def test_memory_inbox_filters_project_scoped_memories(self): + base = { + "path": "wiki/memories/example.md", + "memory_type": "project", + "scope": "project", + "status": "active", + "date_captured": "2026-05-05T00:00:00Z", + "source": "unit test", + "review_status": "pending", + "tags": ["memory"], + "tldr": "Project memory.", + "snippet": "Project memory.", + } + records = [ + {**base, "name": "alpha-note", "title": "Alpha note", "project": "alpha"}, + {**base, "name": "beta-note", "title": "Beta note", "project": "beta"}, + { + **base, + "name": "global-note", + "title": "Global note", + "scope": "global", + "project": "", + }, + ] + + inbox = memory_inbox(records, project="alpha") + + self.assertEqual(inbox["project"], "alpha") + self.assertEqual([item["name"] for item in inbox["items"]], ["alpha-note", "global-note"]) + def test_memory_inbox_prioritizes_metadata_repairs(self): records = [ { From e88797737e7e3665b770022e2e82ff6958cbdd01 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 20:56:13 -0600 Subject: [PATCH 053/292] Surface captures in memory briefs Include saved raw capture counts, secret-warning counts, redacted capture previews, and capture inbox next actions in CLI and MCP memory briefs. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 4 ++-- link.py | 33 +++++++++++++++++++++++++++++++ mcp_package/link_mcp/server.py | 29 +++++++++++++++++++++++++-- tests/test_link_cli.py | 36 ++++++++++++++++++++++++++++++++++ tests/test_mcp_contract.py | 21 ++++++++++++++++++++ 7 files changed, 121 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6fb40b..0eb5b62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added memory merge/update workflow with `update-memory` and MCP `update_memory`, including update counts, audit logs, backlink rebuilds, and review reset. - Added proposal-only memory extraction with `propose-memories` and MCP `propose_memories` for chat/session notes. - Added agent memory briefs with `link.py brief` and MCP `memory_brief` so agents can prime themselves with relevant local memory before a task. +- Added raw capture status to CLI and MCP memory briefs so session priming surfaces saved captures and secret-warning captures. - Added conflict detection for memory writes, updates, and proposals; contradictory active memories are surfaced before saving unless explicitly allowed. - Added shared memory review action plans so inbox and explanation payloads tell agents whether to review, update, archive, restore, or edit metadata next. - Added project-aware memory boundaries so project-scoped memories can carry a project key and recall/profile/brief keep other explicit projects out of context. diff --git a/LINK.md b/LINK.md index 08c79a5..6881a00 100644 --- a/LINK.md +++ b/LINK.md @@ -288,7 +288,7 @@ Rules: - Use `memory_type: preference` for user preferences, `decision` for choices made, `project` for project context, `fact` for stable facts, and `note` for everything else. - Use `scope: user` for broad personal preferences, `project` for the current project, and `global` for agent-wide principles. - For `scope: project`, include a project key when you know it. `link.py` infers this from repo-local installs; otherwise pass `--project ` or MCP `project`. -- At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory. +- At the start of a session or substantial task, run `python3 link.py brief "" .` or MCP `memory_brief` when available. Treat this as the default way to prime yourself with local memory, review warnings, and saved raw capture status. - For long chat/session notes, prefer `python3 link.py capture-session "" .` or MCP `capture_session`; it stores the raw note locally and returns proposal-only memory candidates. If you do not need to keep the raw note, run `python3 link.py propose-memories "" .` or MCP `propose_memories` instead. Do not write proposals until the human confirms. - Use `python3 link.py capture-inbox .` or MCP `capture_inbox` to review saved raw captures, secret warnings, and the exact accept/redact/delete commands before changing capture state. - When the human approves a captured proposal, run `python3 link.py accept-capture "" . --index ` or MCP `accept_capture`. If it reports a duplicate or conflict, stop and ask whether to update/archive the existing memory instead. diff --git a/README.md b/README.md index 537b986..ae033f9 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ Most agents should start with: | Tool | Use it when | |------|-------------| -| `memory_brief` | You are starting a session or task and need Link to prime the agent with relevant memory. | +| `memory_brief` | You are starting a session or task and need Link to prime the agent with relevant memory, review warnings, and saved capture status. | | `memory_profile` | You need to know what Link remembers about the user/project. | | `memory_inbox` | You need review items with the safest next action for each memory. | | `recall_memory` | You need preferences, decisions, facts, or project context. | @@ -446,7 +446,7 @@ Common endpoints: | `python3 link.py accept-capture [--index N]` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | | `python3 link.py redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | | `python3 link.py delete-capture --confirm` | Delete a saved raw capture after explicit confirmation. | -| `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, and safe memory rules. | +| `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, saved capture status, and safe memory rules. | | `python3 link.py recall "query" [--project slug]` | Search local agent memories. | | `python3 link.py profile [--project slug]` | Show what Link remembers by type, scope, status, and recency. | | `python3 link.py memory-inbox [--project slug]` | Show memories that need review or stronger metadata with next-step commands. | diff --git a/link.py b/link.py index 1a697c6..5ce4a13 100644 --- a/link.py +++ b/link.py @@ -1880,6 +1880,22 @@ def capture_inbox( return 0 +def _capture_review_summary(target: Path, project: str | None = None, limit: int = 3) -> dict[str, object]: + root = _resolve_link_root(target) + captures = _capture_records(target, limit=50, project=project) + warning_count = sum(1 for capture in captures if capture["warning_count"]) + summary = { + "count": len(captures), + "warning_count": warning_count, + "project": _core_normalize_project(project), + "items": captures[:max(1, min(limit, 10))], + "next_action": f'python3 link.py capture-inbox "{root}"', + } + if summary["project"]: + summary["next_action"] = f'python3 link.py capture-inbox "{root}" --project "{summary["project"]}"' + return summary + + def accept_capture( target: Path, capture: str, @@ -2417,6 +2433,14 @@ def brief( return 1 project_name = project or _default_project(target) payload = _memory_brief(wiki_dir, query=query, limit=limit, project=project_name) + payload["captures"] = _capture_review_summary(target, project=project_name) + if payload["captures"]["count"]: + capture_count = payload["captures"]["count"] + payload["agent_guidance"].append( + f"Review {capture_count} saved raw capture{'s' if capture_count != 1 else ''} before accepting or deleting capture state." + ) + if payload["captures"]["warning_count"]: + payload["agent_guidance"].append("Redact raw captures with secret warnings before sharing snippets or using their contents.") if json_output: print(json.dumps(payload, indent=2)) @@ -2447,6 +2471,15 @@ def brief( print(f"- {item['title']} ({item['memory_type']} · {item['scope']})") first_issue = item["issues"][0] print(f" [{first_issue['severity']}] {first_issue['code']}: {first_issue['message']}") + if payload["captures"]["items"]: + print("") + print("Raw captures") + print(f"{payload['captures']['count']} saved · {payload['captures']['warning_count']} with secret-looking warnings") + for capture in payload["captures"]["items"]: + print(f"- {capture['title']} ({capture['path']})") + if capture["secret_warnings"]: + print(" Warnings: " + ", ".join(capture["secret_warnings"])) + print(f" Next: {payload['captures']['next_action']}") print("") print("Agent guidance") for item in payload["agent_guidance"]: diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 3844cd4..fb10b31 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -228,13 +228,23 @@ def _memory_profile(limit: int = 10, project: str = "") -> dict[str, object]: def _memory_brief(query: str = "", limit: int = 6, project: str = "") -> dict[str, object]: - return _core_memory_brief( + project_name = _resolve_project(project) + payload = _core_memory_brief( _memory_records(), query=_clean_text_input(query, max_len=500), limit=limit, review_command="review_memory", - project=_resolve_project(project), + project=project_name, ) + payload["captures"] = _capture_review_summary(project=project_name) + if payload["captures"]["count"]: + capture_count = payload["captures"]["count"] + payload["agent_guidance"].append( + f"Review {capture_count} saved raw capture{'s' if capture_count != 1 else ''} before accepting or deleting capture state." + ) + if payload["captures"]["warning_count"]: + payload["agent_guidance"].append("Redact raw captures with secret warnings before sharing snippets or using their contents.") + return payload def _recall_memories( @@ -448,6 +458,21 @@ def _capture_inbox(limit: int = 20, project: str = "") -> dict[str, object]: } +def _capture_review_summary(project: str = "", limit: int = 3) -> dict[str, object]: + project_name = _core_normalize_project(project) + captures = _capture_records(limit=50, project=project_name) + next_action = "capture_inbox()" + if project_name: + next_action = f'capture_inbox(project="{project_name}")' + return { + "count": len(captures), + "warning_count": sum(1 for capture in captures if capture["warning_count"]), + "project": project_name, + "items": captures[:max(1, min(limit, 10))], + "next_action": next_action, + } + + def _accept_capture( capture: str, index: int = 1, diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 05191ed..9c6b525 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -454,9 +454,45 @@ def test_brief_json(self): self.assertEqual(code, 0) self.assertEqual(payload["selection"], "query") self.assertEqual(payload["profile"]["memory_count"], 1) + self.assertEqual(payload["captures"]["count"], 0) self.assertEqual(payload["relevant_memories"][0]["name"], "prefer-local-personal-memory") self.assertNotIn("body", payload["relevant_memories"][0]) + def test_brief_surfaces_saved_captures_without_secret_values(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + fake_key = "sk-" + ("F" * 24) + with redirect_stdout(StringIO()): + link_cli.capture_session( + target, + f"Remember that brief should surface capture review. Test key {fake_key}", + title="Brief capture", + project="alpha", + json_output=True, + ) + + json_out = StringIO() + with redirect_stdout(json_out): + json_code = link_cli.brief(target, "capture review", project="alpha", json_output=True) + payload = json.loads(json_out.getvalue()) + + text_out = StringIO() + with redirect_stdout(text_out): + text_code = link_cli.brief(target, "capture review", project="alpha") + + self.assertEqual(json_code, 0) + self.assertEqual(text_code, 0) + self.assertEqual(payload["captures"]["project"], "alpha") + self.assertEqual(payload["captures"]["count"], 1) + self.assertEqual(payload["captures"]["warning_count"], 1) + self.assertIn("[redacted-secret]", payload["captures"]["items"][0]["snippet"]) + self.assertIn("capture-inbox", payload["captures"]["next_action"]) + self.assertIn("Redact raw captures", "\n".join(payload["agent_guidance"])) + self.assertNotIn(fake_key, json_out.getvalue()) + self.assertIn("Raw captures", text_out.getvalue()) + self.assertNotIn(fake_key, text_out.getvalue()) + def test_capture_session_writes_raw_note_and_proposes_only(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 54b07e5..7c79941 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -227,10 +227,31 @@ def test_memory_brief_contract(self): self.assertEqual(payload["query"], "local personal memory") self.assertEqual(payload["profile"]["memory_count"], 1) self.assertEqual(payload["review"]["count"], 1) + self.assertEqual(payload["captures"]["count"], 0) self.assertEqual(payload["relevant_memories"][0]["name"], "prefer-local-personal-memory") self.assertNotIn("body", payload["relevant_memories"][0]) self.assertIn("agent_guidance", payload) + def test_memory_brief_surfaces_capture_review_contract(self): + fake_key = "sk-" + ("F" * 24) + capture = json.loads(self.server.capture_session( + f"Remember that MCP brief should surface capture review. Test key {fake_key}", + title="MCP brief capture", + project="alpha", + )) + + raw_payload = self.server.memory_brief("capture review", project="alpha") + payload = json.loads(raw_payload) + + self.assertTrue(capture["captured"]) + self.assertEqual(payload["captures"]["project"], "alpha") + self.assertEqual(payload["captures"]["count"], 1) + self.assertEqual(payload["captures"]["warning_count"], 1) + self.assertIn("[redacted-secret]", payload["captures"]["items"][0]["snippet"]) + self.assertIn("capture_inbox", payload["captures"]["next_action"]) + self.assertIn("Redact raw captures", "\n".join(payload["agent_guidance"])) + self.assertNotIn(fake_key, raw_payload) + def test_capture_session_contract(self): before_memories = list((self.target / "wiki/memories").glob("*.md")) fake_key = "sk-" + ("A" * 24) From ac44757e530c4f9e0b636119890c1b1355bf2793 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 21:01:56 -0600 Subject: [PATCH 054/292] Add confirmed memory forgetting Add permanent memory deletion behind explicit confirmation for CLI and MCP, removing index entries, rebuilding backlinks, and logging without memory body content. --- CHANGELOG.md | 1 + LINK.md | 1 + README.md | 4 ++- link.py | 56 +++++++++++++++++++++++++++++ mcp_package/link_core/memory.py | 64 +++++++++++++++++++++++++++++++++ mcp_package/link_mcp/server.py | 34 +++++++++++++++++- tests/test_link_cli.py | 30 ++++++++++++++++ tests/test_mcp_contract.py | 19 ++++++++++ tests/test_memory_core.py | 29 +++++++++++++++ 9 files changed, 236 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb5b62..ca0fb44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added a first-run demo memory page so Link presents as local agent memory, not only a wiki. - Added Memory Profile views through `link.py profile`, MCP `memory_profile`, `/profile`, and `/api/memory-profile`. - Added reversible memory lifecycle controls with `archive-memory`/`restore-memory` and MCP `archive_memory`/`restore_memory`; archived memories are hidden from recall by default. +- Added confirmed permanent memory deletion with `forget-memory` and MCP `forget_memory` for user-requested local forgetting. - Added Memory Review Inbox with `memory-inbox`, `review-memory`, MCP `memory_inbox`/`review_memory`, `/inbox`, and `/api/memory-inbox`. - Added Explain Memory views with `explain-memory`, MCP `explain_memory`, `/explain-memory`, and `/api/explain-memory` for provenance, review state, lifecycle, graph links, and recall readiness. - Added duplicate protection for `remember`/`remember_memory`; strong duplicate memories are refused unless explicitly allowed. diff --git a/LINK.md b/LINK.md index 6881a00..74d4b9e 100644 --- a/LINK.md +++ b/LINK.md @@ -303,6 +303,7 @@ Rules: - After the human confirms a memory is accurate, run `python3 link.py review-memory "" .`. - Run `python3 link.py explain-memory "" .` when the human asks why an agent knows something or whether a memory is safe to use. - If a memory is stale or wrong, archive it with `python3 link.py archive-memory "" . --reason "why"`. Do not delete memory pages unless the human explicitly asks for permanent removal. +- If the human explicitly asks Link to permanently forget a memory, use `python3 link.py forget-memory "" . --confirm` or MCP `forget_memory` with `confirm: true`. Prefer archive when reversible cleanup is enough. - Restore an archived memory with `python3 link.py restore-memory "" .`. ### 2. Ingest diff --git a/README.md b/README.md index ae033f9..836dc0f 100644 --- a/README.md +++ b/README.md @@ -394,11 +394,12 @@ Most agents should start with: | `accept_capture` | The user approves one proposal from a saved raw capture. | | `redact_capture` | The user approves redacting secret-looking values from a saved raw capture. | | `delete_capture` | The user explicitly confirms deleting a saved raw capture. | +| `forget_memory` | The user explicitly confirms Link should permanently delete a memory. | Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, `propose_memories`, `capture_session`, `capture_inbox`, `accept_capture`, `redact_capture`, `delete_capture`, -`update_memory`, `archive_memory`, `restore_memory`, +`update_memory`, `archive_memory`, `restore_memory`, `forget_memory`, `get_context`, `get_pages`, `get_backlinks`, `get_graph`, `rebuild_backlinks`. Memory write tools return `duplicate_candidates` or `conflict_candidates` when @@ -455,6 +456,7 @@ Common endpoints: | `python3 link.py update-memory "text" [--project slug]` | Merge new text into an existing memory, blocking likely conflicts with other active memories by default. | | `python3 link.py archive-memory ` | Reversibly hide a stale or wrong memory from default recall. | | `python3 link.py restore-memory ` | Restore an archived memory to active recall. | +| `python3 link.py forget-memory --confirm` | Permanently delete a memory after explicit confirmation; archive first if you may need it later. | | `python3 link.py doctor ` | Check structure, graph health, source hygiene, and secret-looking content. | | `python3 link.py doctor --fix` | Create missing structure and repair backlinks safely. | | `python3 link.py rebuild-backlinks ` | Regenerate `wiki/_backlinks.json`. | diff --git a/link.py b/link.py index 5ce4a13..9ca1556 100644 --- a/link.py +++ b/link.py @@ -14,6 +14,7 @@ python link.py profile [target] python link.py archive-memory [target] python link.py restore-memory [target] + python link.py forget-memory [target] --confirm python link.py memory-inbox [target] python link.py review-memory [target] python link.py explain-memory [target] @@ -92,6 +93,7 @@ from link_core.memory import ( count_values as _core_count_values, + forget_memory_page as _core_forget_memory_page, mark_memory_reviewed as _core_mark_memory_reviewed, memory_brief as _core_memory_brief, memory_explanation as _core_memory_explanation, @@ -2265,6 +2267,52 @@ def restore_memory(target: Path, identifier: str, json_output: bool = False) -> return 0 +def forget_memory(target: Path, identifier: str, confirm: bool = False, json_output: bool = False) -> int: + target = target.expanduser().resolve() + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + + def rebuild_memory_backlinks() -> bool: + backlinks = _build_backlinks(wiki_dir) + (wiki_dir / "_backlinks.json").write_text(json.dumps(backlinks, indent=2) + "\n", encoding="utf-8") + return True + + result = _core_forget_memory_page( + wiki_dir, + identifier, + confirm=confirm, + records=_memory_records(wiki_dir), + timestamp=_utc_timestamp(), + log_writer=lambda ts, operation, description, lines: _append_log( + wiki_dir, + ts, + operation, + description, + lines, + ), + rebuild_backlinks=rebuild_memory_backlinks, + ) + if json_output: + print(json.dumps(result, indent=2)) + return 0 if result.get("forgotten") else 1 + + if not result.get("found"): + print(f"Memory not found: {identifier}", file=sys.stderr) + return 1 + if result.get("confirmation_required"): + print("Confirmation required.") + print(f"Run: python3 link.py forget-memory \"{result['name']}\" . --confirm") + return 1 + + print("Memory forgotten") + print(f"Title: {result['title']}") + print(f"Deleted: {result['path']}") + print(f"Backlinks rebuilt: {'yes' if result.get('backlinks_rebuilt') else 'no'}") + return 0 + + def memory_inbox( target: Path, limit: int = 20, @@ -2844,6 +2892,12 @@ def main(argv: list[str] | None = None) -> int: restore_cmd.add_argument("target", nargs="?", default=".") restore_cmd.add_argument("--json", action="store_true", help="print machine-readable status") + forget_cmd = sub.add_parser("forget-memory", help="permanently delete a memory after explicit confirmation") + forget_cmd.add_argument("identifier", help="memory page name, title, or path") + forget_cmd.add_argument("target", nargs="?", default=".") + forget_cmd.add_argument("--confirm", action="store_true", help="required to delete the memory") + forget_cmd.add_argument("--json", action="store_true", help="print machine-readable status") + inbox_cmd = sub.add_parser("memory-inbox", help="show memories that need review") inbox_cmd.add_argument("target", nargs="?", default=".") inbox_cmd.add_argument("--limit", type=int, default=20) @@ -2971,6 +3025,8 @@ def main(argv: list[str] | None = None) -> int: return archive_memory(Path(args.target), args.identifier, reason=args.reason, json_output=args.json) if args.command == "restore-memory": return restore_memory(Path(args.target), args.identifier, json_output=args.json) + if args.command == "forget-memory": + return forget_memory(Path(args.target), args.identifier, confirm=args.confirm, json_output=args.json) if args.command == "memory-inbox": return memory_inbox( Path(args.target), diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 7832ed4..039796c 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -718,6 +718,18 @@ def update_memory_index( index_path.write_text(text, encoding="utf-8") +def remove_memory_from_index(index_path: Path, page_name: str) -> bool: + if not index_path.exists(): + return False + text = index_path.read_text(encoding="utf-8", errors="replace") + lines = text.splitlines() + filtered = [line for line in lines if f"[[{page_name}]]" not in line] + if len(filtered) == len(lines): + return False + index_path.write_text("\n".join(filtered).rstrip() + "\n", encoding="utf-8") + return True + + def replace_markdown_body(text: str, body: str) -> str: if text.startswith("---\n"): end = text.find("\n---", 4) @@ -797,6 +809,58 @@ def set_memory_status( } +def forget_memory_page( + wiki_dir: Path, + identifier: str, + confirm: bool = False, + records: Iterable[Mapping[str, object]] | None = None, + log_writer: MemoryLogWriter | None = None, + timestamp: str = "", + rebuild_backlinks: Callable[[], bool] | None = None, +) -> dict[str, object]: + page_path, record, error = resolve_memory_page(wiki_dir, identifier, records=records) + if error: + return { + "forgotten": False, + "found": False, + "error": error, + "confirmation_required": False, + } + assert page_path is not None and record is not None + + payload: dict[str, object] = { + "forgotten": False, + "found": True, + "name": record["name"], + "path": record["path"], + "title": record["title"], + "confirmation_required": not confirm, + } + if not confirm: + return payload + + page_path.unlink() + index_updated = remove_memory_from_index(wiki_dir / "index.md", page_path.stem) + backlinks_rebuilt = rebuild_backlinks() if rebuild_backlinks else False + payload.update({ + "forgotten": True, + "confirmation_required": False, + "index_updated": index_updated, + "backlinks_rebuilt": bool(backlinks_rebuilt), + }) + if log_writer: + log_writer( + timestamp, + "forget-memory", + f"Forgot memory {payload['path']}", + [ + f"Title: {payload['title']}", + "Deleted memory page only; memory body was not logged.", + ], + ) + return payload + + def mark_memory_reviewed( wiki_dir: Path, identifier: str, diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index fb10b31..e06ed66 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -67,7 +67,8 @@ "update_memory on the existing memory instead of forcing a duplicate. " "If it returns conflict candidates, ask the user whether to update or " "archive the older memory before forcing a conflict. " - "Use archive_memory instead of deleting stale or wrong memories." + "Use archive_memory instead of deleting stale or wrong memories; use " + "forget_memory only when the user explicitly asks for permanent deletion." ), ) @@ -79,6 +80,7 @@ from link_core.memory import ( count_values as _core_count_values, + forget_memory_page as _core_forget_memory_page, mark_memory_reviewed as _core_mark_memory_reviewed, memory_brief as _core_memory_brief, memory_explanation as _core_memory_explanation, @@ -637,6 +639,25 @@ def _set_memory_status(identifier: str, status: str, reason: str = "") -> dict[s return result +def _forget_memory(identifier: str, confirm: bool = False) -> dict[str, object]: + def rebuild_memory_backlinks() -> bool: + rebuilt = json.loads(rebuild_backlinks()) + return bool(rebuilt.get("rebuilt")) + + result = _core_forget_memory_page( + WIKI_DIR, + _clean_text_input(identifier, max_len=300), + confirm=confirm, + records=_memory_records(), + timestamp=_utc_timestamp(), + log_writer=_append_log, + rebuild_backlinks=rebuild_memory_backlinks, + ) + if result.get("forgotten"): + _cache.clear() + return result + + def _mark_memory_reviewed(identifier: str, note: str = "") -> dict[str, object]: result = _core_mark_memory_reviewed( WIKI_DIR, @@ -1003,6 +1024,17 @@ def restore_memory(identifier: str) -> str: return json.dumps(result, ensure_ascii=False) +@mcp.tool() +def forget_memory(identifier: str, confirm: bool = False) -> str: + """Permanently delete a memory after explicit user confirmation. + + Prefer archive_memory for reversible cleanup. Use forget_memory only when + the user asks Link to permanently forget a memory; the tool refuses to + delete unless confirm is true and never logs the memory body. + """ + return json.dumps(_forget_memory(identifier, confirm=confirm), ensure_ascii=False) + + @mcp.tool() def remember_memory( memory: str, diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 9c6b525..f174a35 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -902,6 +902,36 @@ def test_archive_memory_hides_from_default_recall_and_restore_reenables(self): link_cli.recall(target, "local personal memory") self.assertIn("Prefer local personal memory", out.getvalue()) + def test_forget_memory_requires_confirmation_and_deletes_page(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + memory_path = target / "wiki/memories/prefer-local-personal-memory.md" + + denied_out = StringIO() + with redirect_stdout(denied_out): + denied_code = link_cli.forget_memory(target, "prefer-local-personal-memory", json_output=True) + denied = json.loads(denied_out.getvalue()) + self.assertEqual(denied_code, 1) + self.assertFalse(denied["forgotten"]) + self.assertTrue(denied["confirmation_required"]) + self.assertTrue(memory_path.exists()) + + forget_out = StringIO() + with redirect_stdout(forget_out): + forget_code = link_cli.forget_memory(target, "prefer-local-personal-memory", confirm=True, json_output=True) + forgotten = json.loads(forget_out.getvalue()) + log_text = (target / "wiki/log.md").read_text(encoding="utf-8") + index_text = (target / "wiki/index.md").read_text(encoding="utf-8") + + self.assertEqual(forget_code, 0) + self.assertTrue(forgotten["forgotten"]) + self.assertTrue(forgotten["backlinks_rebuilt"]) + self.assertFalse(memory_path.exists()) + self.assertNotIn("[[prefer-local-personal-memory]]", index_text) + self.assertIn("forget-memory", log_text) + self.assertNotIn("local personal memory for agents", log_text) + def test_archive_memory_json_not_found(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 7c79941..45ed9f9 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -446,6 +446,25 @@ def test_archive_and_restore_memory_contract(self): self.assertEqual(restored["status"], "active") self.assertEqual(recall_restored["memories"][0]["name"], "prefer-local-personal-memory") + def test_forget_memory_contract(self): + memory_path = self.target / "wiki/memories/prefer-local-personal-memory.md" + + denied = json.loads(self.server.forget_memory("prefer-local-personal-memory")) + forgotten = json.loads(self.server.forget_memory("prefer-local-personal-memory", confirm=True)) + recall = json.loads(self.server.recall_memory("local personal memory", include_archived=True)) + log_text = (self.target / "wiki/log.md").read_text(encoding="utf-8") + index_text = (self.target / "wiki/index.md").read_text(encoding="utf-8") + + self.assertFalse(denied["forgotten"]) + self.assertTrue(denied["confirmation_required"]) + self.assertTrue(forgotten["forgotten"]) + self.assertTrue(forgotten["backlinks_rebuilt"]) + self.assertFalse(memory_path.exists()) + self.assertEqual(recall["count"], 0) + self.assertNotIn("[[prefer-local-personal-memory]]", index_text) + self.assertIn("forget-memory", log_text) + self.assertNotIn("local personal memory for agents", log_text) + def test_remember_memory_contract(self): payload = json.loads(self.server.remember_memory( "User prefers release branches for Link work.", diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 7cb0ab1..07e90f1 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -9,6 +9,7 @@ from link_core.memory import ( # noqa: E402 extract_wikilinks, + forget_memory_page, mark_memory_reviewed, memory_brief, memory_conflict_candidates, @@ -659,6 +660,34 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str self.assertNotIn("archive_reason:", restored_text) self.assertEqual(logged[-1][1], "restore-memory") + (wiki / "index.md").write_text("### memories\n- [[prefer-focused-commits]] - old entry\n", encoding="utf-8") + denied = forget_memory_page( + wiki, + "prefer-focused-commits", + records=memory_records(wiki), + log_writer=log_writer, + timestamp="2026-05-05T06:00:00Z", + rebuild_backlinks=lambda: rebuilds.append(True) or True, + ) + forgotten = forget_memory_page( + wiki, + "prefer-focused-commits", + confirm=True, + records=memory_records(wiki), + log_writer=log_writer, + timestamp="2026-05-05T06:00:00Z", + rebuild_backlinks=lambda: rebuilds.append(True) or True, + ) + + self.assertFalse(denied["forgotten"]) + self.assertTrue(denied["confirmation_required"]) + self.assertTrue(forgotten["forgotten"]) + self.assertFalse(memory_path.exists()) + self.assertTrue(forgotten["index_updated"]) + self.assertNotIn("[[prefer-focused-commits]]", (wiki / "index.md").read_text(encoding="utf-8")) + self.assertEqual(logged[-1][1], "forget-memory") + self.assertNotIn("User prefers focused commits", "\n".join(logged[-1][3])) + def test_write_memory_page_creates_index_log_and_blocks_duplicates(self): root = Path(tempfile.mkdtemp(prefix="link-memory-write-")) wiki = root / "wiki" From c7f797fe8f14a9bc59997f18608136f5bb3b181f Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 21:04:26 -0600 Subject: [PATCH 055/292] Report memory backlog in doctor Add memory review and raw capture backlog checks to doctor, and exclude proposal-only memory captures from pending source ingest status. --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 2 +- link.py | 21 ++++++++++++++++++++- tests/test_link_cli.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca0fb44..1ef4c67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added proposal-only memory extraction with `propose-memories` and MCP `propose_memories` for chat/session notes. - Added agent memory briefs with `link.py brief` and MCP `memory_brief` so agents can prime themselves with relevant local memory before a task. - Added raw capture status to CLI and MCP memory briefs so session priming surfaces saved captures and secret-warning captures. +- Added memory review and raw capture backlog checks to `link.py doctor`, while excluding proposal-only raw captures from ingest-status pending source counts. - Added conflict detection for memory writes, updates, and proposals; contradictory active memories are surfaced before saving unless explicitly allowed. - Added shared memory review action plans so inbox and explanation payloads tell agents whether to review, update, archive, restore, or edit metadata next. - Added project-aware memory boundaries so project-scoped memories can carry a project key and recall/profile/brief keep other explicit projects out of context. diff --git a/LINK.md b/LINK.md index 74d4b9e..1d40750 100644 --- a/LINK.md +++ b/LINK.md @@ -367,7 +367,7 @@ python3 link.py doctor . Use `python3 link.py doctor . --fix` only for safe mechanical repairs: creating missing Link directories/files and rebuilding `_backlinks.json`. Do not use it as a substitute for content review. -Treat doctor errors as blockers. Doctor warnings are quality issues to triage with the human. It checks required structure, dead links, stale backlinks, index drift, TLDR/query summaries, Sources sections, `source_count` consistency, isolated graph pages, raw-source coverage, and secret-looking filenames or file contents. +Treat doctor errors as blockers. Doctor warnings are quality issues to triage with the human. It checks required structure, dead links, stale backlinks, index drift, TLDR/query summaries, Sources sections, `source_count` consistency, isolated graph pages, raw-source coverage, memory review state, raw capture backlog, and secret-looking filenames or file contents. Run these checks and report findings: diff --git a/README.md b/README.md index 836dc0f..954ece1 100644 --- a/README.md +++ b/README.md @@ -457,7 +457,7 @@ Common endpoints: | `python3 link.py archive-memory ` | Reversibly hide a stale or wrong memory from default recall. | | `python3 link.py restore-memory ` | Restore an archived memory to active recall. | | `python3 link.py forget-memory --confirm` | Permanently delete a memory after explicit confirmation; archive first if you may need it later. | -| `python3 link.py doctor ` | Check structure, graph health, source hygiene, and secret-looking content. | +| `python3 link.py doctor ` | Check structure, graph health, source hygiene, memory review state, raw capture backlog, and secret-looking content. | | `python3 link.py doctor --fix` | Create missing structure and repair backlinks safely. | | `python3 link.py rebuild-backlinks ` | Regenerate `wiki/_backlinks.json`. | | `python3 link.py verify-mcp ` | Verify `link-mcp` import and print MCP config. | diff --git a/link.py b/link.py index 9ca1556..fcdbf3a 100644 --- a/link.py +++ b/link.py @@ -1033,7 +1033,10 @@ def _raw_source_files(raw_dir: Path) -> list[Path]: for path in sorted(raw_dir.rglob("*")): if not path.is_file() or path.name.startswith("."): continue - if any(part in SKIP_SCAN_DIRS for part in path.relative_to(raw_dir).parts): + rel_parts = path.relative_to(raw_dir).parts + if rel_parts and rel_parts[0] == "memory-captures": + continue + if any(part in SKIP_SCAN_DIRS for part in rel_parts): continue files.append(path) return files @@ -1368,6 +1371,22 @@ def doctor(target: Path, fix: bool = False) -> int: else: print("OK graph has no isolated wiki pages") + memory_review = _memory_inbox(wiki_dir, limit=8, include_archived=True) + if memory_review["review_count"]: + names = ", ".join(item["name"] for item in memory_review["items"][:8]) + warnings.append(f"memories need review: {names}") + else: + print("OK memories are reviewed") + + captures = _capture_records(target, limit=50) + capture_warning_count = sum(1 for capture in captures if capture["warning_count"]) + if captures: + warnings.append(f"raw memory captures pending review: {len(captures)}") + else: + print("OK no raw memory captures pending review") + if capture_warning_count: + warnings.append(f"raw memory captures with secret warnings: {capture_warning_count}") + uningested = _find_uningested_raw(target) if uningested: warnings.append("raw files not referenced by wiki pages: " + ", ".join(uningested[:8])) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index f174a35..e1d9fb6 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -79,6 +79,7 @@ def test_doctor_accepts_demo_wiki(self): self.assertIn("OK wiki pages have summaries", out.getvalue()) self.assertIn("OK source-backed pages cite sources", out.getvalue()) self.assertIn("OK no sensitive-looking file contents", out.getvalue()) + self.assertIn("memories need review", out.getvalue()) def test_ingest_status_accepts_demo_wiki(self): tmp = Path(tempfile.mkdtemp(prefix="link-ingest-test-")) @@ -94,6 +95,34 @@ def test_ingest_status_accepts_demo_wiki(self): self.assertIn("Pending ingest: 0", out.getvalue()) self.assertIn("Backlinks: current", out.getvalue()) + def test_capture_session_is_not_pending_source_ingest(self): + tmp = Path(tempfile.mkdtemp(prefix="link-ingest-test-")) + target = tmp / "demo" + create_demo_quiet(target) + with redirect_stdout(StringIO()): + link_cli.capture_session( + target, + "Remember that capture notes are proposal-only memory backlog.", + title="Capture backlog", + json_output=True, + ) + + ingest_out = StringIO() + with redirect_stdout(ingest_out): + ingest_code = link_cli.ingest_status(target, json_output=True) + ingest = json.loads(ingest_out.getvalue()) + + doctor_out = StringIO() + with redirect_stdout(doctor_out): + doctor_code = link_cli.doctor(target) + + self.assertEqual(ingest_code, 0) + self.assertEqual(ingest["raw_count"], 3) + self.assertEqual(ingest["pending_count"], 0) + self.assertEqual(doctor_code, 0) + self.assertIn("raw memory captures pending review: 1", doctor_out.getvalue()) + self.assertNotIn("raw files not referenced by wiki pages", doctor_out.getvalue()) + def test_ingest_status_reports_pending_raw_file(self): tmp = Path(tempfile.mkdtemp(prefix="link-ingest-test-")) target = tmp / "demo" From 6e145e5036724b44ec7582493134b0ddb92d6e20 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 21:06:11 -0600 Subject: [PATCH 056/292] Surface forget as secondary memory action Add low-priority forget action hints to memory review and explanation payloads so permanent deletion is discoverable without becoming the default action. --- CHANGELOG.md | 1 + mcp_package/link_core/memory.py | 18 ++++++++++++++++++ tests/test_memory_core.py | 3 +++ 3 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef4c67..8d831e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added Memory Profile views through `link.py profile`, MCP `memory_profile`, `/profile`, and `/api/memory-profile`. - Added reversible memory lifecycle controls with `archive-memory`/`restore-memory` and MCP `archive_memory`/`restore_memory`; archived memories are hidden from recall by default. - Added confirmed permanent memory deletion with `forget-memory` and MCP `forget_memory` for user-requested local forgetting. +- Added low-priority forget actions to memory review/explanation payloads so permanent deletion is discoverable but never the default next step. - Added Memory Review Inbox with `memory-inbox`, `review-memory`, MCP `memory_inbox`/`review_memory`, `/inbox`, and `/api/memory-inbox`. - Added Explain Memory views with `explain-memory`, MCP `explain_memory`, `/explain-memory`, and `/api/explain-memory` for provenance, review state, lifecycle, graph links, and recall readiness. - Added duplicate protection for `remember`/`remember_memory`; strong duplicate memories are refused unless explicitly allowed. diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 039796c..4e3f8b9 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -397,6 +397,15 @@ def add(action: dict[str, object]) -> None: arguments={"identifier": name}, priority="medium", )) + add(_memory_action( + kind="forget", + label="Forget", + description="Permanently delete only after explicit user confirmation.", + command=f'python3 link.py forget-memory "{name}" . --confirm', + tool="forget_memory", + arguments={"identifier": name, "confirm": True}, + priority="low", + )) return actions if issue_codes & {"invalid_review_status", "invalid_memory_type", "invalid_scope", "missing_source", "missing_date_captured"}: @@ -471,6 +480,15 @@ def add(action: dict[str, object]) -> None: arguments={"identifier": name, "reason": "why"}, priority="medium", )) + add(_memory_action( + kind="forget", + label="Forget", + description="Permanently delete only after explicit user confirmation.", + command=f'python3 link.py forget-memory "{name}" . --confirm', + tool="forget_memory", + arguments={"identifier": name, "confirm": True}, + priority="low", + )) return actions diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 07e90f1..6f4ffde 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -117,6 +117,9 @@ def test_memory_inbox_returns_action_plan(self): self.assertIn("review-memory", item["primary_action"]["command"]) self.assertEqual(inbox["next_actions"][0]["kind"], "review") self.assertIn("actions", item) + forget_action = next(action for action in item["actions"] if action["kind"] == "forget") + self.assertEqual(forget_action["tool"], "forget_memory") + self.assertTrue(forget_action["arguments"]["confirm"]) def test_memory_inbox_filters_project_scoped_memories(self): base = { From 33cc4280a47b1614c3df29f19ea14722aebb12a5 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 21:10:13 -0600 Subject: [PATCH 057/292] Show memory actions in web views Render primary and secondary memory action commands on the web inbox and explanation pages, including low-priority forget actions while keeping writes CLI/MCP-only. --- CHANGELOG.md | 1 + serve.py | 55 ++++++++++++++++++++++++++++++++++----------- tests/test_serve.py | 33 +++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d831e7..5c1fa3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added reversible memory lifecycle controls with `archive-memory`/`restore-memory` and MCP `archive_memory`/`restore_memory`; archived memories are hidden from recall by default. - Added confirmed permanent memory deletion with `forget-memory` and MCP `forget_memory` for user-requested local forgetting. - Added low-priority forget actions to memory review/explanation payloads so permanent deletion is discoverable but never the default next step. +- Added memory action commands to web inbox and explanation pages, including review, update, archive, restore, and low-priority forget actions. - Added Memory Review Inbox with `memory-inbox`, `review-memory`, MCP `memory_inbox`/`review_memory`, `/inbox`, and `/api/memory-inbox`. - Added Explain Memory views with `explain-memory`, MCP `explain_memory`, `/explain-memory`, and `/api/explain-memory` for provenance, review state, lifecycle, graph links, and recall readiness. - Added duplicate protection for `remember`/`remember_memory`; strong duplicate memories are refused unless explicitly allowed. diff --git a/serve.py b/serve.py index 388505e..dd014af 100644 --- a/serve.py +++ b/serve.py @@ -1036,17 +1036,7 @@ def _render_memory_card(record: dict[str, object], include_issues: bool = False) f'{html.escape(str(issue["code"]))}: {html.escape(str(issue["message"]))}' for issue in record["issues"] ) + "" - actions = "" - for action in record.get("actions") or _memory_action_hints(record): - label = html.escape(str(action.get("label") or "")) - if action.get("href"): - label_html = f'{label}' - else: - label_html = label - actions += ( - f'
{label_html}' - f'{html.escape(str(action.get("command") or ""))}
' - ) + actions = _render_memory_action_commands(record.get("actions") or _memory_action_hints(record)) summary_html = f'

{html.escape(summary)}

' if summary else "" return ( '
' @@ -1054,11 +1044,31 @@ def _render_memory_card(record: dict[str, object], include_issues: bool = False) f'
{html.escape(meta)}
' f'{summary_html}' f'{issues_html}' - f'
{actions}
' + f'{actions}' '
' ) +def _render_memory_action_commands(actions: list[dict[str, object]] | tuple[dict[str, object], ...]) -> str: + if not actions: + return "" + rows = "" + for action in actions: + label = html.escape(str(action.get("label") or "")) + if action.get("href"): + label_html = f'{label}' + else: + label_html = label + priority = str(action.get("priority") or "") + priority_html = f'{html.escape(priority)}' if priority else "" + rows += ( + f'
{label_html}' + f'{priority_html}' + f'{html.escape(str(action.get("command") or ""))}
' + ) + return f'
{rows}
' + + def _render_memory_section(title: str, records: list[dict[str, object]], empty: str, href: str = "", include_issues: bool = False) -> str: heading_link = f'view all' if href else "" heading = f'

{html.escape(title)}

{heading_link}
' @@ -1253,12 +1263,22 @@ def _render_inbox(): f'{html.escape(str(issue["code"]))}: {html.escape(str(issue["message"]))}' for issue in item["issues"] ) + primary = item.get("primary_action") or {} + primary_html = "" + if primary: + primary_html = ( + f'

Next: {html.escape(str(primary.get("label") or ""))} ' + f'- {html.escape(str(primary.get("description") or ""))}

' + ) + actions_html = _render_memory_action_commands(item.get("actions") or []) items += ( f'
  • {html.escape(str(item["title"]))}' f'
    {html.escape(meta)}
    ' f'' f'{f"{html.escape(str(summary))}" if summary else ""}' - f'
      {issues}
  • ' + f'
      {issues}
    ' + f'{primary_html}' + f'{actions_html}' ) content = f"
      {items}
    " @@ -1297,6 +1317,14 @@ def _render_explain_memory(identifier: str): f'

    Review Issues

      {issues}
    ' if issues else "

    Review Issues

    No detected issues.

    " ) + primary = review.get("primary_action") or {} + primary_html = "" + if primary: + primary_html = ( + f'

    Next: {html.escape(str(primary.get("label") or ""))} ' + f'- {html.escape(str(primary.get("description") or ""))}

    ' + ) + action_html = f'

    Actions

    {primary_html}{_render_memory_action_commands(review.get("actions") or [])}' graph_html = ( f'

    Graph

    ' f'

    Forward: {html.escape(", ".join(graph["forward"]) or "none")}

    ' @@ -1322,6 +1350,7 @@ def _render_explain_memory(identifier: str): f'
    Path{html.escape(str(provenance["path"]))}
    ' f'
    ' f'{issue_html}' + f'{action_html}' f'{graph_html}' f'{log_html}' f'

    Memory Body

    {body_html}' diff --git a/tests/test_serve.py b/tests/test_serve.py index 429716a..d1c26d5 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -271,6 +271,39 @@ def test_memory_dashboard_surfaces_raw_captures_and_secret_warnings(self): self.assertIn("redact-capture", html) self.assertNotIn(fake_key, html) + def test_memory_inbox_and_explain_render_action_commands(self): + wiki = self.make_wiki() + write_page( + wiki, + "memories/prefer-reviewable-memory.md", + ( + "---\n" + "type: memory\n" + "title: \"Prefer reviewable memory\"\n" + "memory_type: preference\n" + "scope: user\n" + "status: active\n" + "date_captured: \"2026-05-05T00:00:00Z\"\n" + "source: \"unit test\"\n" + "review_status: pending\n" + "---\n\n" + "# Prefer reviewable memory\n\n" + "> **TLDR:** User prefers visible memory actions.\n\n" + "## Memory\n\nUser prefers visible memory actions.\n" + ), + ) + + inbox_html = serve._render_inbox() + explain_html = serve._render_explain_memory("prefer-reviewable-memory") + + self.assertIn("Next: Review", inbox_html) + self.assertIn("review-memory", inbox_html) + self.assertIn("archive-memory", inbox_html) + self.assertIn("forget-memory", inbox_html) + self.assertIn("

    Actions

    ", explain_html) + self.assertIn("Next: Review", explain_html) + self.assertIn("forget-memory", explain_html) + def test_memory_dashboard_filters_project_memory_and_captures(self): wiki = self.make_wiki() write_page( From 1e2909459e2676df15e875bda1036449a9fd59a5 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 21:15:09 -0600 Subject: [PATCH 058/292] Add memory audit report Add read-only CLI and MCP memory audit surfaces covering profile health, review backlog, raw captures, risk factors, and safe next actions. --- CHANGELOG.md | 1 + LINK.md | 1 + README.md | 4 +- link.py | 105 +++++++++++++++++++++++++++++++++ mcp_package/link_mcp/server.py | 66 +++++++++++++++++++++ tests/test_link_cli.py | 36 +++++++++++ tests/test_mcp_contract.py | 20 +++++++ 7 files changed, 232 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1fa3d..ae6c257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added proposal-only memory extraction with `propose-memories` and MCP `propose_memories` for chat/session notes. - Added agent memory briefs with `link.py brief` and MCP `memory_brief` so agents can prime themselves with relevant local memory before a task. - Added raw capture status to CLI and MCP memory briefs so session priming surfaces saved captures and secret-warning captures. +- Added `memory-audit` and MCP `memory_audit` for a read-only health report covering memory backlog, raw captures, risk factors, and next actions. - Added memory review and raw capture backlog checks to `link.py doctor`, while excluding proposal-only raw captures from ingest-status pending source counts. - Added conflict detection for memory writes, updates, and proposals; contradictory active memories are surfaced before saving unless explicitly allowed. - Added shared memory review action plans so inbox and explanation payloads tell agents whether to review, update, archive, restore, or edit metadata next. diff --git a/LINK.md b/LINK.md index 1d40750..288aadf 100644 --- a/LINK.md +++ b/LINK.md @@ -295,6 +295,7 @@ Rules: - If capture results report `secret_warnings`, ask the human whether to redact the raw capture. Use `python3 link.py redact-capture "" .` or MCP `redact_capture`; it replaces secret-looking values and logs labels/counts only. - If the human asks to remove a raw capture, run `python3 link.py delete-capture "" . --confirm` or MCP `delete_capture` with `confirm: true`. Never delete captures without explicit confirmation. - Run `python3 link.py recall "" .` before answering questions that might depend on remembered preferences or project decisions. +- Run `python3 link.py memory-audit .` or MCP `memory_audit` when the human asks what needs attention in Link memory. - Run `python3 link.py profile .` when the human asks what Link knows or when you need a quick overview of remembered preferences, decisions, and project context. - Run `python3 link.py memory-inbox .` or MCP `memory_inbox` to find pending, stale, invalid, or underspecified memories and follow each item's primary action. Pass `--project ` or MCP `project` when reviewing a specific project. - If `remember` reports a duplicate candidate, inspect it with `python3 link.py explain-memory "" .` and merge new information with `python3 link.py update-memory "" "new detail" .` instead of creating another one. Use `--allow-duplicate` only when the human confirms it should be separate. diff --git a/README.md b/README.md index 954ece1..dceb9be 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,7 @@ Most agents should start with: | Tool | Use it when | |------|-------------| | `memory_brief` | You are starting a session or task and need Link to prime the agent with relevant memory, review warnings, and saved capture status. | +| `memory_audit` | You need one read-only health report for memory review backlog, raw captures, and next actions. | | `memory_profile` | You need to know what Link remembers about the user/project. | | `memory_inbox` | You need review items with the safest next action for each memory. | | `recall_memory` | You need preferences, decisions, facts, or project context. | @@ -396,7 +397,7 @@ Most agents should start with: | `delete_capture` | The user explicitly confirms deleting a saved raw capture. | | `forget_memory` | The user explicitly confirms Link should permanently delete a memory. | -Full tool set: `memory_brief`, `memory_profile`, `memory_inbox`, `review_memory`, +Full tool set: `memory_brief`, `memory_audit`, `memory_profile`, `memory_inbox`, `review_memory`, `explain_memory`, `search_wiki`, `recall_memory`, `remember_memory`, `propose_memories`, `capture_session`, `capture_inbox`, `accept_capture`, `redact_capture`, `delete_capture`, `update_memory`, `archive_memory`, `restore_memory`, `forget_memory`, @@ -448,6 +449,7 @@ Common endpoints: | `python3 link.py redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | | `python3 link.py delete-capture --confirm` | Delete a saved raw capture after explicit confirmation. | | `python3 link.py brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, saved capture status, and safe memory rules. | +| `python3 link.py memory-audit [--project slug]` | Read-only health report for memory review backlog, raw captures, risk factors, and next actions. | | `python3 link.py recall "query" [--project slug]` | Search local agent memories. | | `python3 link.py profile [--project slug]` | Show what Link remembers by type, scope, status, and recency. | | `python3 link.py memory-inbox [--project slug]` | Show memories that need review or stronger metadata with next-step commands. | diff --git a/link.py b/link.py index fcdbf3a..12bab79 100644 --- a/link.py +++ b/link.py @@ -12,6 +12,7 @@ python link.py brief ["task or question"] [target] python link.py recall "query" [target] python link.py profile [target] + python link.py memory-audit [target] python link.py archive-memory [target] python link.py restore-memory [target] python link.py forget-memory [target] --confirm @@ -2608,6 +2609,102 @@ def profile(target: Path, limit: int = 10, project: str | None = None, json_outp return 0 +def _memory_audit_payload(target: Path, wiki_dir: Path, limit: int = 10, project: str | None = None) -> dict[str, object]: + project_name = project or _default_project(target) + profile_data = _memory_profile(wiki_dir, limit=limit, project=project_name) + inbox = _memory_inbox(wiki_dir, limit=limit, include_archived=True, project=project_name) + captures = _capture_review_summary(target, project=project_name, limit=min(limit, 10)) + risk_factors: list[dict[str, object]] = [] + if inbox["review_count"]: + risk_factors.append({ + "code": "memory_review_backlog", + "count": inbox["review_count"], + "message": f'{inbox["review_count"]} memory item(s) need review or cleanup.', + }) + if captures["count"]: + risk_factors.append({ + "code": "raw_capture_backlog", + "count": captures["count"], + "message": f'{captures["count"]} raw capture(s) are waiting for review.', + }) + if captures["warning_count"]: + risk_factors.append({ + "code": "capture_secret_warnings", + "count": captures["warning_count"], + "message": f'{captures["warning_count"]} raw capture(s) contain secret-looking values.', + }) + + root = _resolve_link_root(target) + project_arg = f' --project "{project_name}"' if project_name else "" + next_actions = [ + { + "label": "Review memory inbox", + "command": f'python3 link.py memory-inbox "{root}"{project_arg}', + "recommended": bool(inbox["review_count"]), + }, + { + "label": "Review raw captures", + "command": f'python3 link.py capture-inbox "{root}"{project_arg}', + "recommended": bool(captures["count"]), + }, + { + "label": "Run doctor", + "command": f'python3 link.py doctor "{root}"', + "recommended": not risk_factors, + }, + ] + return { + "status": "needs_attention" if risk_factors else "healthy", + "project": _core_normalize_project(project_name), + "profile": profile_data, + "inbox": inbox, + "captures": captures, + "risk_factors": risk_factors, + "next_actions": next_actions, + } + + +def memory_audit(target: Path, limit: int = 10, project: str | None = None, json_output: bool = False) -> int: + target = target.expanduser().resolve() + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + payload = _memory_audit_payload(target, wiki_dir, limit=limit, project=project) + + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + + print(f"Link memory audit: {target}") + if payload["project"]: + print(f"Project: {payload['project']}") + print(f"Status: {payload['status']}") + print("") + profile_data = payload["profile"] + print( + f"Memories: {profile_data['memory_count']} total · " + f"{profile_data['active_count']} active · " + f"{profile_data['review_count']} need review" + ) + print( + f"Raw captures: {payload['captures']['count']} saved · " + f"{payload['captures']['warning_count']} with secret-looking warnings" + ) + if payload["risk_factors"]: + print("") + print("Needs attention") + for factor in payload["risk_factors"]: + print(f"- {factor['code']}: {factor['message']}") + print("") + print("Next actions") + for action in payload["next_actions"]: + marker = "recommended" if action["recommended"] else "optional" + print(f"- {action['label']} ({marker})") + print(f" {action['command']}") + return 0 + + def _check_link_mcp_import(python_cmd: str) -> dict[str, object]: code = ( "import json, link_mcp; " @@ -2900,6 +2997,12 @@ def main(argv: list[str] | None = None) -> int: profile_cmd.add_argument("--project", default=None, help="include user/global memories plus this project's memories") profile_cmd.add_argument("--json", action="store_true", help="print machine-readable profile") + audit_cmd = sub.add_parser("memory-audit", help="audit memory health, review backlog, and raw captures") + audit_cmd.add_argument("target", nargs="?", default=".") + audit_cmd.add_argument("--limit", type=int, default=10) + audit_cmd.add_argument("--project", default=None, help="include user/global memories plus this project's memories") + audit_cmd.add_argument("--json", action="store_true", help="print machine-readable audit") + archive_cmd = sub.add_parser("archive-memory", help="archive a stale or unwanted memory") archive_cmd.add_argument("identifier", help="memory page name, title, or path") archive_cmd.add_argument("target", nargs="?", default=".") @@ -3040,6 +3143,8 @@ def main(argv: list[str] | None = None) -> int: return brief(Path(args.target), query=args.query, limit=args.limit, project=args.project, json_output=args.json) if args.command == "profile": return profile(Path(args.target), limit=args.limit, project=args.project, json_output=args.json) + if args.command == "memory-audit": + return memory_audit(Path(args.target), limit=args.limit, project=args.project, json_output=args.json) if args.command == "archive-memory": return archive_memory(Path(args.target), args.identifier, reason=args.reason, json_output=args.json) if args.command == "restore-memory": diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index e06ed66..586272e 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -249,6 +249,62 @@ def _memory_brief(query: str = "", limit: int = 6, project: str = "") -> dict[st return payload +def _memory_audit(limit: int = 10, project: str = "") -> dict[str, object]: + parsed_limit = _parse_limit(limit, default=10, max_limit=50) + project_name = _resolve_project(project) + profile = _memory_profile(limit=parsed_limit, project=project_name) + inbox = _memory_inbox(limit=parsed_limit, include_archived=True, project=project_name) + captures = _capture_review_summary(project=project_name, limit=min(parsed_limit, 10)) + risk_factors: list[dict[str, object]] = [] + if inbox["review_count"]: + risk_factors.append({ + "code": "memory_review_backlog", + "count": inbox["review_count"], + "message": f'{inbox["review_count"]} memory item(s) need review or cleanup.', + }) + if captures["count"]: + risk_factors.append({ + "code": "raw_capture_backlog", + "count": captures["count"], + "message": f'{captures["count"]} raw capture(s) are waiting for review.', + }) + if captures["warning_count"]: + risk_factors.append({ + "code": "capture_secret_warnings", + "count": captures["warning_count"], + "message": f'{captures["warning_count"]} raw capture(s) contain secret-looking values.', + }) + project_arg = f', project="{project_name}"' if project_name else "" + return { + "status": "needs_attention" if risk_factors else "healthy", + "project": _core_normalize_project(project_name), + "profile": profile, + "inbox": inbox, + "captures": captures, + "risk_factors": risk_factors, + "next_actions": [ + { + "label": "Review memory inbox", + "tool": "memory_inbox", + "command": f"memory_inbox(include_archived=true{project_arg})", + "recommended": bool(inbox["review_count"]), + }, + { + "label": "Review raw captures", + "tool": "capture_inbox", + "command": f"capture_inbox({project_arg.lstrip(', ')})" if project_arg else "capture_inbox()", + "recommended": bool(captures["count"]), + }, + { + "label": "Explain a memory", + "tool": "explain_memory", + "command": 'explain_memory(identifier="")', + "recommended": False, + }, + ], + } + + def _recall_memories( query: str, limit: int = 10, @@ -935,6 +991,16 @@ def memory_profile(limit: int = 10, project: str = "") -> str: return json.dumps(_memory_profile(limit=limit, project=project), ensure_ascii=False) +@mcp.tool() +def memory_audit(limit: int = 10, project: str = "") -> str: + """Audit local memory health, review backlog, and raw capture state. + + Use this when the user asks what Link knows, what needs attention, or + whether local agent memory is ready for use. + """ + return json.dumps(_memory_audit(limit=limit, project=project), ensure_ascii=False) + + @mcp.tool() def memory_inbox(limit: int = 20, include_archived: bool = False, project: str = "") -> str: """List memories that need user review. diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index e1d9fb6..75ab2f1 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -522,6 +522,42 @@ def test_brief_surfaces_saved_captures_without_secret_values(self): self.assertIn("Raw captures", text_out.getvalue()) self.assertNotIn(fake_key, text_out.getvalue()) + def test_memory_audit_reports_backlog_without_secret_values(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) + target = tmp / "demo" + create_demo_quiet(target) + fake_key = "sk-" + ("G" * 24) + with redirect_stdout(StringIO()): + link_cli.capture_session( + target, + f"Remember that memory audit should show capture risk. Test key {fake_key}", + title="Audit capture", + project="alpha", + json_output=True, + ) + + json_out = StringIO() + with redirect_stdout(json_out): + json_code = link_cli.memory_audit(target, project="alpha", json_output=True) + payload = json.loads(json_out.getvalue()) + + text_out = StringIO() + with redirect_stdout(text_out): + text_code = link_cli.memory_audit(target, project="alpha") + + self.assertEqual(json_code, 0) + self.assertEqual(text_code, 0) + self.assertEqual(payload["status"], "needs_attention") + self.assertEqual(payload["project"], "alpha") + self.assertEqual(payload["captures"]["warning_count"], 1) + self.assertIn("capture_secret_warnings", [factor["code"] for factor in payload["risk_factors"]]) + self.assertIn("memory-inbox", payload["next_actions"][0]["command"]) + self.assertIn("capture-inbox", payload["next_actions"][1]["command"]) + self.assertNotIn(fake_key, json_out.getvalue()) + self.assertIn("Link memory audit", text_out.getvalue()) + self.assertIn("needs_attention", text_out.getvalue()) + self.assertNotIn(fake_key, text_out.getvalue()) + def test_capture_session_writes_raw_note_and_proposes_only(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 45ed9f9..690d3a3 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -252,6 +252,26 @@ def test_memory_brief_surfaces_capture_review_contract(self): self.assertIn("Redact raw captures", "\n".join(payload["agent_guidance"])) self.assertNotIn(fake_key, raw_payload) + def test_memory_audit_contract(self): + fake_key = "sk-" + ("G" * 24) + capture = json.loads(self.server.capture_session( + f"Remember that MCP audit should show capture risk. Test key {fake_key}", + title="MCP audit capture", + project="alpha", + )) + + raw_payload = self.server.memory_audit(project="alpha") + payload = json.loads(raw_payload) + + self.assertTrue(capture["captured"]) + self.assertEqual(payload["status"], "needs_attention") + self.assertEqual(payload["project"], "alpha") + self.assertEqual(payload["captures"]["warning_count"], 1) + self.assertIn("capture_secret_warnings", [factor["code"] for factor in payload["risk_factors"]]) + self.assertEqual(payload["next_actions"][0]["tool"], "memory_inbox") + self.assertEqual(payload["next_actions"][1]["tool"], "capture_inbox") + self.assertNotIn(fake_key, raw_payload) + def test_capture_session_contract(self): before_memories = list((self.target / "wiki/memories").glob("*.md")) fake_key = "sk-" + ("A" * 24) From f6ada3b663f8e1172811d9f4368f8abef75c0bca Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 21:19:12 -0600 Subject: [PATCH 059/292] Add web memory audit view Expose the read-only memory audit report through /audit and /api/memory-audit, with project-aware links, backlog stats, capture risk summaries, and web tests. --- CHANGELOG.md | 1 + README.md | 1 + serve.py | 120 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_serve.py | 52 +++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6c257..6c258eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added agent memory briefs with `link.py brief` and MCP `memory_brief` so agents can prime themselves with relevant local memory before a task. - Added raw capture status to CLI and MCP memory briefs so session priming surfaces saved captures and secret-warning captures. - Added `memory-audit` and MCP `memory_audit` for a read-only health report covering memory backlog, raw captures, risk factors, and next actions. +- Added `/audit` and `/api/memory-audit` so the local web UI exposes the same read-only memory audit report. - Added memory review and raw capture backlog checks to `link.py doctor`, while excluding proposal-only raw captures from ingest-status pending source counts. - Added conflict detection for memory writes, updates, and proposals; contradictory active memories are surfaced before saving unless explicitly allowed. - Added shared memory review action plans so inbox and explanation payloads tell agents whether to review, update, archive, restore, or edit metadata next. diff --git a/README.md b/README.md index dceb9be..592355b 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,7 @@ Common endpoints: |----------|-------------| | `GET /api/pages` | All pages with title, type, tags, aliases, maturity, and TLDR. | | `GET /api/memory-dashboard?project=` | Read-only memory dashboard data, including saved raw captures and secret-warning counts. | +| `GET /api/memory-audit?project=` | Read-only memory health report with backlog, capture risks, and next actions. | | `GET /api/memory-profile?project=` | Counts and recent memories for the local memory profile. | | `GET /api/memory-inbox?project=` | Memories that need review or metadata cleanup. | | `GET /api/explain-memory?memory=` | Provenance, lifecycle, graph links, review state, and recall readiness. | diff --git a/serve.py b/serve.py index dd014af..1ae4e1b 100644 --- a/serve.py +++ b/serve.py @@ -414,6 +414,71 @@ def _memory_dashboard(limit: int = 12, project: str | None = None) -> dict[str, } +def _memory_audit(limit: int = 10, project: str | None = None) -> dict[str, object]: + limit = max(1, min(limit, 50)) + project_name = _core_normalize_project(project) + profile = _memory_profile(limit=limit, project=project_name) + inbox = _memory_inbox(limit=limit, include_archived=True, project=project_name) + captures = _capture_records(limit=min(limit, 10), project=project_name) + capture_warning_count = sum(1 for capture in captures if capture["warning_count"]) + risk_factors: list[dict[str, object]] = [] + if inbox["review_count"]: + risk_factors.append({ + "code": "memory_review_backlog", + "count": inbox["review_count"], + "message": f'{inbox["review_count"]} memory item(s) need review or cleanup.', + }) + if captures: + risk_factors.append({ + "code": "raw_capture_backlog", + "count": len(captures), + "message": f"{len(captures)} raw capture(s) are waiting for review.", + }) + if capture_warning_count: + risk_factors.append({ + "code": "capture_secret_warnings", + "count": capture_warning_count, + "message": f"{capture_warning_count} raw capture(s) contain secret-looking values.", + }) + project_query = f"?project={urllib.parse.quote(project_name, safe='')}" if project_name else "" + project_arg = f' --project "{project_name}"' if project_name else "" + return { + "status": "needs_attention" if risk_factors else "healthy", + "project": project_name, + "profile": profile, + "inbox": inbox, + "captures": { + "count": len(captures), + "warning_count": capture_warning_count, + "items": captures, + }, + "risk_factors": risk_factors, + "next_actions": [ + { + "label": "Review memory inbox", + "detail": "Review pending, stale, invalid, or underspecified memories.", + "href": f"/inbox{project_query}", + "command": f"python3 link.py memory-inbox .{project_arg}", + "recommended": bool(inbox["review_count"]), + }, + { + "label": "Review raw captures", + "detail": "Accept, redact, or delete saved proposal-only raw captures.", + "href": f"/memory{project_query}", + "command": f"python3 link.py capture-inbox .{project_arg}", + "recommended": bool(captures), + }, + { + "label": "Run doctor", + "detail": "Check graph, source, memory, raw capture, and secret hygiene.", + "href": "", + "command": "python3 link.py doctor .", + "recommended": not risk_factors, + }, + ], + } + + def _json_for_script(data) -> str: """Serialize JSON for direct embedding inside a + """ @@ -1127,14 +1259,35 @@ def _render_memory_action_commands(actions: list[dict[str, object]] | tuple[dict label_html = label priority = str(action.get("priority") or "") priority_html = f'{html.escape(priority)}' if priority else "" + button_html = _render_memory_action_button(action) rows += ( - f'
    {label_html}' - f'{priority_html}' + f'
    {label_html}' + f'{priority_html}{button_html}' f'{html.escape(str(action.get("command") or ""))}
    ' ) return f'
    {rows}
    ' +def _render_memory_action_button(action: dict[str, object]) -> str: + kind = str(action.get("kind") or "") + if kind not in {"review", "archive", "restore"}: + return "" + arguments = action.get("arguments") if isinstance(action.get("arguments"), dict) else {} + identifier = str(arguments.get("identifier") or "") + if not identifier: + return "" + labels = { + "review": "Mark reviewed", + "archive": "Archive", + "restore": "Restore", + } + return ( + f'' + ) + + def _render_memory_section(title: str, records: list[dict[str, object]], empty: str, href: str = "", include_issues: bool = False) -> str: heading_link = f'view all' if href else "" heading = f'

    {html.escape(title)}

    {heading_link}
    ' @@ -2098,9 +2251,7 @@ def _rebuild_backlinks_payload() -> dict[str, object]: bl_path = WIKI_DIR / "_backlinks.json" bl_path.write_text(json.dumps(result, indent=2), encoding="utf-8") # Invalidate pages cache so next request picks up the new backlinks mtime. - global _pages_cache, _pages_cache_mtime - _pages_cache = None - _pages_cache_mtime = 0.0 + _invalidate_pages_cache() return {"rebuilt": True, "pages": len(result.get("backlinks", {}))} @@ -2149,6 +2300,35 @@ def do_POST(self): ) self._json(result) return + if path in {"/api/review-memory", "/api/archive-memory", "/api/restore-memory"}: + payload, error, status = self._read_json_body() + if error: + self._json({"updated": False, "error": error}, status=status) + return + assert payload is not None + identifier = _clean_text_input(payload.get("memory") or payload.get("identifier"), max_len=300) + if not identifier: + self._json({"updated": False, "error": "memory required"}, status=400) + return + try: + if path == "/api/review-memory": + result = _mark_memory_reviewed( + identifier, + note=_clean_text_input(payload.get("note"), max_len=500), + ) + elif path == "/api/archive-memory": + result = _set_memory_status( + identifier, + "archived", + reason=_clean_text_input(payload.get("reason"), max_len=500), + ) + else: + result = _set_memory_status(identifier, "active") + except ValueError as exc: + self._json({"updated": False, "error": str(exc)}, status=404) + return + self._json(result) + return self._json({"error": "POST endpoint not found"}, status=404) def do_GET(self): @@ -2232,6 +2412,8 @@ def do_GET(self): )) elif path == "/api/propose-memories": self._json({"error": "use POST with JSON body: {\"text\": \"...\"}"}, status=405) + elif path in {"/api/review-memory", "/api/archive-memory", "/api/restore-memory"}: + self._json({"error": "use POST with JSON body: {\"memory\": \"...\"}"}, status=405) elif path == "/api/explain-memory": identifier = query.get("memory", [""])[0].strip() or query.get("name", [""])[0].strip() if not identifier: diff --git a/tests/test_serve.py b/tests/test_serve.py index eba2cb0..669a79b 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -55,6 +55,19 @@ def run_handler(method: str, path: str, body: bytes = b"", headers: dict[str, st return status, payload +def post_json(path: str, payload: dict[str, object]): + body = json.dumps(payload).encode("utf-8") + return run_handler( + "POST", + path, + body, + { + "Content-Type": "application/json", + "Content-Length": str(len(body)), + }, + ) + + class ServeTests(unittest.TestCase): def make_wiki(self) -> Path: tmp = Path(tempfile.mkdtemp(prefix="link-test-")) @@ -299,12 +312,74 @@ def test_memory_inbox_and_explain_render_action_commands(self): self.assertIn("Next: Review", inbox_html) self.assertIn("review-memory", inbox_html) + self.assertIn('data-memory-action="review"', inbox_html) + self.assertIn('data-memory="prefer-reviewable-memory"', inbox_html) self.assertIn("archive-memory", inbox_html) + self.assertIn('data-memory-action="archive"', inbox_html) self.assertIn("forget-memory", inbox_html) self.assertIn("

    Actions

    ", explain_html) self.assertIn("Next: Review", explain_html) self.assertIn("forget-memory", explain_html) + def test_memory_action_post_endpoints_update_pages(self): + wiki = self.make_wiki() + page = write_page( + wiki, + "memories/prefer-web-review.md", + ( + "---\n" + "type: memory\n" + "title: \"Prefer web review\"\n" + "memory_type: preference\n" + "scope: user\n" + "status: active\n" + "date_captured: \"2026-05-05T00:00:00Z\"\n" + "source: \"unit test\"\n" + "review_status: pending\n" + "---\n\n" + "# Prefer web review\n\n" + "> **TLDR:** User prefers safe web memory review.\n\n" + "## Memory\n\nUser prefers safe web memory review.\n" + ), + ) + + review_status, review_payload = post_json( + "/api/review-memory", + {"memory": "prefer-web-review", "note": "confirmed from web"}, + ) + archive_status, archive_payload = post_json( + "/api/archive-memory", + {"memory": "prefer-web-review", "reason": "validated archive"}, + ) + restore_status, restore_payload = post_json( + "/api/restore-memory", + {"memory": "Prefer web review"}, + ) + text = page.read_text(encoding="utf-8") + log_text = (wiki / "log.md").read_text(encoding="utf-8") + + self.assertEqual(review_status, 200) + self.assertTrue(review_payload["updated"]) + self.assertEqual(review_payload["review_status"], "reviewed") + self.assertEqual(archive_status, 200) + self.assertEqual(archive_payload["status"], "archived") + self.assertEqual(restore_status, 200) + self.assertEqual(restore_payload["status"], "active") + self.assertIn("review_status: reviewed", text) + self.assertIn('review_note: "confirmed from web"', text) + self.assertIn("status: active", text) + self.assertIn("review-memory", log_text) + self.assertIn("archive-memory", log_text) + self.assertIn("restore-memory", log_text) + + def test_memory_action_post_requires_memory_identifier(self): + wiki = self.make_wiki() + status, payload = post_json("/api/review-memory", {}) + + self.assertEqual(status, 400) + self.assertFalse(payload["updated"]) + self.assertEqual(payload["error"], "memory required") + def test_memory_audit_page_and_api_report_backlog(self): wiki = self.make_wiki() write_page( From 9e8027cf4c71ce2770c6378fa7eeb3df0ff0704e Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 21:29:53 -0600 Subject: [PATCH 062/292] Add web raw capture inbox Add a dedicated /captures page and /api/capture-inbox endpoint so saved proposal-only session captures, secret warnings, and review commands are visible outside the memory dashboard. --- LINK.md | 1 + README.md | 7 ++++++ serve.py | 60 ++++++++++++++++++++++++++++++++++++++++++--- tests/test_serve.py | 47 +++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/LINK.md b/LINK.md index d5307ed..e574157 100644 --- a/LINK.md +++ b/LINK.md @@ -569,6 +569,7 @@ Used during query to find related pages, and during lint to detect orphans and b | `POST /api/review-memory` | JSON `{ "memory": "name", "note": "optional" }`; mark a memory reviewed | | `POST /api/archive-memory` | JSON `{ "memory": "name", "reason": "optional" }`; archive a memory from default recall | | `POST /api/restore-memory` | JSON `{ "memory": "name" }`; restore archived memory to active recall | +| `GET /api/capture-inbox?project=` | Saved raw captures with redacted snippets, warnings, and commands | | `GET /api/search?q=` | Ranked search — title, alias, tag, fulltext. Returns scores + snippets | | `GET /api/context?topic=` | Best matching page + inbound/forward links in one call | | `GET /api/graph` | All nodes + edges for graph visualization | diff --git a/README.md b/README.md index f4ad156..6b00a84 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Open: - `http://localhost:3000` - `http://localhost:3000/memory` - `http://localhost:3000/audit` +- `http://localhost:3000/captures` - `http://localhost:3000/graph` Then check the demo: @@ -93,6 +94,11 @@ See what agents can remember, what needs review, and what changed recently. Check memory health in one place: review backlog, saved raw captures, secret-warning captures, and the next safe commands to run. +### Raw Capture Inbox + +Review proposal-only session notes before they become durable memory. Secret-like +values are redacted in snippets and called out for local cleanup. + ### Knowledge Graph Inspect relationships between source pages, concepts, entities, explorations, and @@ -438,6 +444,7 @@ Common endpoints: | `GET /api/memory-audit?project=` | Read-only memory health report with backlog, capture risks, and next actions. | | `GET /api/memory-profile?project=` | Counts and recent memories for the local memory profile. | | `GET /api/memory-inbox?project=` | Memories that need review or metadata cleanup. | +| `GET /api/capture-inbox?project=` | Saved raw captures with redacted snippets, secret-warning labels, and review commands. | | `GET /api/explain-memory?memory=` | Provenance, lifecycle, graph links, review state, and recall readiness. | | `POST /api/propose-memories` | Returns memory proposals without writing pages. | | `POST /api/review-memory` | JSON `{ "memory": "name", "note": "optional" }`; marks a memory reviewed. | diff --git a/serve.py b/serve.py index 5b9e8fe..3a063a0 100644 --- a/serve.py +++ b/serve.py @@ -322,7 +322,7 @@ def _memory_dashboard_next_actions( actions.append({ "label": "Redact capture warnings", "detail": f"{capture_warning_count} raw capture{'s' if capture_warning_count != 1 else ''} contain secret-looking values.", - "href": "/memory", + "href": "/captures", "command": "python3 link.py redact-capture raw/memory-captures/.md .", "priority": "high", }) @@ -356,7 +356,7 @@ def _memory_dashboard_next_actions( actions.append({ "label": "Review raw captures", "detail": f"{capture_count} saved raw capture{'s' if capture_count != 1 else ''} can be accepted, redacted, or deleted.", - "href": "/memory", + "href": "/captures", "command": "python3 link.py accept-capture raw/memory-captures/.md . --index 1", "priority": "medium", }) @@ -423,6 +423,18 @@ def _capture_records(limit: int = 12, project: str | None = None) -> list[dict[s return records[:limit] +def _capture_inbox(limit: int = 20, project: str | None = None) -> dict[str, object]: + limit = max(1, min(limit, 50)) + project_name = _core_normalize_project(project) + captures = _capture_records(limit=limit, project=project_name) + return { + "count": len(captures), + "warning_count": sum(1 for capture in captures if capture["warning_count"]), + "project": project_name, + "captures": captures, + } + + def _memory_dashboard(limit: int = 12, project: str | None = None) -> dict[str, object]: limit = max(1, min(limit, 50)) project_name = _core_normalize_project(project) @@ -526,7 +538,7 @@ def _memory_audit(limit: int = 10, project: str | None = None) -> dict[str, obje { "label": "Review raw captures", "detail": "Accept, redact, or delete saved proposal-only raw captures.", - "href": f"/memory{project_query}", + "href": f"/captures{project_query}", "command": f"python3 link.py capture-inbox .{project_arg}", "recommended": bool(captures), }, @@ -1059,6 +1071,7 @@ def _header_html(): memory audit inbox + captures profile log all pages @@ -1492,6 +1505,36 @@ def _render_memory_audit(project: str | None = None): return _layout("Memory Audit", body) +def _render_captures(project: str | None = None): + inbox = _capture_inbox(limit=50, project=project) + stats = ( + f'
    ' + f'
    {inbox["count"]}captures
    ' + f'
    {inbox["warning_count"]}warnings
    ' + f'
    ' + ) + warning_html = "" + if inbox["warning_count"]: + warning_html = ( + f'
    Needs redaction' + f'

    {inbox["warning_count"]} raw capture' + f'{"s contain" if inbox["warning_count"] != 1 else " contains"} secret-looking values.

    ' + f'python3 link.py redact-capture raw/memory-captures/<capture>.md .
    ' + ) + body = ( + f'' + f'

    Raw Capture Inbox

    ' + f'
    ' + f'

    Saved proposal-only session notes waiting for human review before they become durable memory.

    ' + f'{"

    Project: " + html.escape(str(inbox["project"])) + "

    " if inbox["project"] else ""}' + f'{stats}' + f'{warning_html}' + f'{_render_capture_section(inbox["captures"])}' + f'
    ' + ) + return _layout("Raw Capture Inbox", body) + + def _render_inbox(project: str | None = None): inbox = _memory_inbox(limit=50, project=project) review_count = inbox["review_count"] @@ -2354,6 +2397,8 @@ def do_GET(self): self._ok(_render_memory_audit(project=query.get("project", [""])[0])) elif path == "/inbox": self._ok(_render_inbox(project=query.get("project", [""])[0])) + elif path == "/captures": + self._ok(_render_captures(project=query.get("project", [""])[0])) elif path == "/explain-memory": identifier = query.get("memory", [""])[0].strip() or query.get("name", [""])[0].strip() self._ok(_render_explain_memory(identifier)) @@ -2410,6 +2455,15 @@ def do_GET(self): include_archived=include_archived, project=query.get("project", [""])[0], )) + elif path == "/api/capture-inbox": + limit, error = _parse_search_limit(query.get("limit", ["20"])[0]) + if error: + self._json({"error": error}, status=400) + else: + self._json(_capture_inbox( + limit=limit, + project=query.get("project", [""])[0], + )) elif path == "/api/propose-memories": self._json({"error": "use POST with JSON body: {\"text\": \"...\"}"}, status=405) elif path in {"/api/review-memory", "/api/archive-memory", "/api/restore-memory"}: diff --git a/tests/test_serve.py b/tests/test_serve.py index 669a79b..39752e0 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -93,6 +93,7 @@ def test_layout_handles_search_enter_key(self): self.assertIn("data-theme-toggle", html) self.assertIn("localStorage.getItem('link-theme')", html) self.assertIn('audit', html) + self.assertIn('captures', html) def test_css_has_mobile_overflow_guards(self): self.assertIn("* { box-sizing: border-box; margin: 0; padding: 0; }", serve.CSS) @@ -285,6 +286,52 @@ def test_memory_dashboard_surfaces_raw_captures_and_secret_warnings(self): self.assertIn("redact-capture", html) self.assertNotIn(fake_key, html) + def test_capture_inbox_page_and_api_redact_secret_values(self): + wiki = self.make_wiki() + capture_dir = wiki.parent / "raw" / "memory-captures" + capture_dir.mkdir(parents=True) + fake_key = "sk-" + ("K" * 24) + (capture_dir / "alpha.md").write_text( + "---\n" + "title: \"Alpha capture\"\n" + "source_type: conversation\n" + "date_captured: \"2026-05-05T00:00:00Z\"\n" + "project: \"alpha\"\n" + "---\n\n" + "# Alpha capture\n\n" + "## Notes\n\n" + f"Remember that capture inbox is first class. Test key {fake_key}\n", + encoding="utf-8", + ) + (capture_dir / "beta.md").write_text( + "---\n" + "title: \"Beta capture\"\n" + "source_type: conversation\n" + "date_captured: \"2026-05-05T00:00:00Z\"\n" + "project: \"beta\"\n" + "---\n\n" + "# Beta capture\n\n" + "## Notes\n\n" + "Remember that beta capture stays separate.\n", + encoding="utf-8", + ) + + status, payload = run_handler("GET", "/api/capture-inbox?project=alpha") + html = serve._render_captures(project="alpha") + + self.assertEqual(status, 200) + self.assertEqual(payload["project"], "alpha") + self.assertEqual(payload["count"], 1) + self.assertEqual(payload["warning_count"], 1) + self.assertEqual(payload["captures"][0]["secret_warnings"], ["OpenAI API key"]) + self.assertIn("[redacted-secret]", payload["captures"][0]["snippet"]) + self.assertNotIn(fake_key, json.dumps(payload)) + self.assertIn("Raw Capture Inbox", html) + self.assertIn("Alpha capture", html) + self.assertNotIn("Beta capture", html) + self.assertIn("redact-capture", html) + self.assertNotIn(fake_key, html) + def test_memory_inbox_and_explain_render_action_commands(self): wiki = self.make_wiki() write_page( From da2c32626e8f837b2e7e42ca9265d4b751ba2d99 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 21:32:51 -0600 Subject: [PATCH 063/292] Add web memory brief Expose /brief and /api/memory-brief so local web and HTTP clients can get startup memory context, review warnings, and raw capture status before agent work. --- LINK.md | 1 + README.md | 7 ++++ serve.py | 99 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_serve.py | 56 +++++++++++++++++++++++++ 4 files changed, 163 insertions(+) diff --git a/LINK.md b/LINK.md index e574157..4e2326b 100644 --- a/LINK.md +++ b/LINK.md @@ -565,6 +565,7 @@ Used during query to find related pages, and during lint to detect orphans and b | Endpoint | Description | |----------|-------------| | `GET /api/pages` | All pages with title, type, tags, aliases, maturity, tldr | +| `GET /api/memory-brief?q=&project=` | Startup memory context: relevant memories, review warnings, capture status, and safe rules | | `POST /api/propose-memories` | Propose memories from JSON `{ "text": "..." }` without writing pages | | `POST /api/review-memory` | JSON `{ "memory": "name", "note": "optional" }`; mark a memory reviewed | | `POST /api/archive-memory` | JSON `{ "memory": "name", "reason": "optional" }`; archive a memory from default recall | diff --git a/README.md b/README.md index 6b00a84..48ad990 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ python3 serve.py Open: - `http://localhost:3000` +- `http://localhost:3000/brief` - `http://localhost:3000/memory` - `http://localhost:3000/audit` - `http://localhost:3000/captures` @@ -89,6 +90,11 @@ See what agents can remember, what needs review, and what changed recently. Link Memory Dashboard in dark mode

    +### Memory Brief + +Prime an agent before work with relevant memories, review warnings, raw capture +status, and safe memory rules. + ### Memory Audit Check memory health in one place: review backlog, saved raw captures, @@ -441,6 +447,7 @@ Common endpoints: |----------|-------------| | `GET /api/pages` | All pages with title, type, tags, aliases, maturity, and TLDR. | | `GET /api/memory-dashboard?project=` | Read-only memory dashboard data, including saved raw captures and secret-warning counts. | +| `GET /api/memory-brief?q=&project=` | Startup memory context for an agent, including relevant memories, review warnings, and capture status. | | `GET /api/memory-audit?project=` | Read-only memory health report with backlog, capture risks, and next actions. | | `GET /api/memory-profile?project=` | Counts and recent memories for the local memory profile. | | `GET /api/memory-inbox?project=` | Memories that need review or metadata cleanup. | diff --git a/serve.py b/serve.py index 3a063a0..9eb9d8b 100644 --- a/serve.py +++ b/serve.py @@ -14,6 +14,7 @@ count_values as _core_count_values, is_active_memory as _core_is_active_memory, memory_action_hints as _core_memory_action_hints, + memory_brief as _core_memory_brief, memory_explanation as _core_memory_explanation, memory_inbox as _core_memory_inbox, memory_profile as _core_memory_profile, @@ -435,6 +436,43 @@ def _capture_inbox(limit: int = 20, project: str | None = None) -> dict[str, obj } +def _capture_review_summary(project: str | None = None, limit: int = 3) -> dict[str, object]: + project_name = _core_normalize_project(project) + captures = _capture_records(limit=50, project=project_name) + project_query = f"?project={urllib.parse.quote(project_name, safe='')}" if project_name else "" + project_arg = f' --project "{project_name}"' if project_name else "" + return { + "count": len(captures), + "warning_count": sum(1 for capture in captures if capture["warning_count"]), + "project": project_name, + "href": f"/captures{project_query}", + "command": f"python3 link.py capture-inbox .{project_arg}", + "items": captures[:max(1, min(limit, 10))], + } + + +def _memory_brief(query: str = "", limit: int = 6, project: str | None = None) -> dict[str, object]: + limit = max(1, min(limit, 20)) + project_name = _core_normalize_project(project) + payload = _core_memory_brief( + _memory_records(), + query=query, + limit=limit, + review_command="review-memory", + project=project_name, + ) + captures = _capture_review_summary(project=project_name, limit=min(limit, 10)) + payload["captures"] = captures + if captures["count"]: + capture_count = captures["count"] + payload["agent_guidance"].append( + f"Review {capture_count} saved raw capture{'s' if capture_count != 1 else ''} before accepting or deleting capture state." + ) + if captures["warning_count"]: + payload["agent_guidance"].append("Redact raw captures with secret warnings before sharing snippets or using their contents.") + return payload + + def _memory_dashboard(limit: int = 12, project: str | None = None) -> dict[str, object]: limit = max(1, min(limit, 50)) project_name = _core_normalize_project(project) @@ -854,6 +892,12 @@ def _flush_blockquote(): .memory-profile { margin: 18px 0; } .memory-profile .summary { color: var(--muted); font-family: sans-serif; margin-bottom: 16px; } .memory-profile .memory-meta { color: var(--subtle); font-size: 12px; font-family: sans-serif; } +.brief-form { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0; font-family: sans-serif; } +.brief-form input { flex: 1 1 220px; min-width: 0; padding: 6px 8px; border: 1px solid var(--border); + border-radius: 4px; background: var(--surface); color: var(--text); } +.brief-form button { border: 1px solid var(--border); background: var(--button-bg); color: var(--button-text); + border-radius: 4px; padding: 6px 10px; cursor: pointer; } +.brief-form button:hover { background: var(--button-hover); } .memory-issues { margin-top: 6px; } .memory-issues li { border: none; padding: 0; color: var(--muted); font-size: 13px; } .memory-issues .severity { font-family: sans-serif; font-size: 11px; text-transform: uppercase; color: #8a6d3b; } @@ -1068,6 +1112,7 @@ def _header_html(): +
    +
    - - +
    """ @@ -1165,7 +1194,8 @@ def _footer_html(): return '
    Link — local agent memory · github
    ' -def _layout(title, body): +def _layout(title, body, page_class: str = ""): + body_class = f' class="{html.escape(page_class, quote=True)}"' if page_class else "" return f""" @@ -1176,7 +1206,7 @@ def _layout(title, body): - + {_header_html()}
    {body} @@ -1257,7 +1287,20 @@ def _render_home(): if not cats: sections = "

    Wiki is empty. Drop sources into raw/ and tell your agent to ingest them.

    " - return _layout("Link", f"

    Link

    Local agent memory. Knowledge compounds here.

    {stats}{sections}") + lanes = ( + '
    ' + '

    1. Sources become wiki knowledge

    ' + '

    Drop files into raw/ and say ingest raw/file.md into Link. ' + 'Link creates source-backed pages, concepts, backlinks, index entries, and logs.

    ' + '

    2. Remember saves agent memory

    ' + '

    Say remember that ... when a preference, decision, or project fact should affect future agents. ' + 'Ingest alone does not silently personalize recall.

    ' + '

    3. Query uses both safely

    ' + '

    Ask query Link for ... or open a memory brief. Link combines reviewed memory, wiki pages, and graph context.

    ' + '
    ' + ) + + return _layout("Link", f"

    Link

    Local agent memory. Knowledge compounds here.

    {lanes}{stats}{sections}") def _render_page(page_path): @@ -1819,6 +1862,8 @@ def _render_graph(): var resetButton = document.getElementById('graph-reset'); var labelsButton = document.getElementById('graph-labels'); var motionButton = document.getElementById('graph-motion'); + var fullscreenButton = document.getElementById('graph-fullscreen'); + var frameEl = document.getElementById('graph-frame'); var status = document.getElementById('graph-status'); var inspector = document.getElementById('graph-inspector'); var inspectorTitle = document.getElementById('graph-inspector-title'); @@ -2148,6 +2193,19 @@ def _render_graph(): updateStatus(); }} + function setFullscreen(next) {{ + if (!frameEl || !fullscreenButton) return; + frameEl.classList.toggle('is-fullscreen', next); + fullscreenButton.setAttribute('aria-pressed', next ? 'true' : 'false'); + fullscreenButton.textContent = next ? 'Exit fullscreen' : 'Fullscreen'; + window.setTimeout(function() {{ + resize(); + autoFit(); + updateStatus(); + draw(); + }}, 0); + }} + canvas.addEventListener('mousedown', function(e) {{ var rect = canvas.getBoundingClientRect(); var sx = e.clientX - rect.left, sy = e.clientY - rect.top; @@ -2242,7 +2300,14 @@ def _render_graph(): if (e.key === '-' || e.key === '_') {{ zoom = Math.max(0.15, zoom * 0.9); updateStatus(); e.preventDefault(); }} if (e.key === '0') {{ resetView(); e.preventDefault(); }} if (e.key === 'Enter' && hoverNode) {{ openNode(hoverNode); e.preventDefault(); }} - if (e.key === 'Escape') {{ selectedNode = null; updateInspector(); updateStatus(); e.preventDefault(); }} + if (e.key === 'Escape') {{ + if (frameEl && frameEl.classList.contains('is-fullscreen')) {{ + setFullscreen(false); + }} else {{ + selectedNode = null; updateInspector(); updateStatus(); + }} + e.preventDefault(); + }} if (e.key === 'l' || e.key === 'L') {{ showAllLabels = !showAllLabels; if (labelsButton) labelsButton.setAttribute('aria-pressed', showAllLabels ? 'true' : 'false'); @@ -2258,6 +2323,9 @@ def _render_graph(): if (motionButton) motionButton.addEventListener('click', function() {{ setMotionPaused(!motionPaused); }}); + if (fullscreenButton) fullscreenButton.addEventListener('click', function() {{ + setFullscreen(!frameEl.classList.contains('is-fullscreen')); + }}); if (inspectorOpen) inspectorOpen.addEventListener('click', function() {{ openNode(selectedNode); }}); window.addEventListener('resize', function() {{ resize(); if (fitted) autoFit(); updateStatus(); }}); @@ -2278,10 +2346,14 @@ def _render_graph(): body = ( f'' f'

    Knowledge Graph

    ' + f'

    For large wikis, use fullscreen, zoom, pan, and sparse labels. ' + f'The graph is for exploring neighborhoods, not reading every label at once.

    ' + f'
    ' f'
    ' f'' f'' f'' + f'' f'' f'{node_count} nodes · {edge_count} edges' f'
    ' @@ -2297,9 +2369,10 @@ def _render_graph(): f'' f'
    ' f'
    {legend_items}
    ' + f'' f'{graph_js}' ) - return _layout("Knowledge Graph", body) + return _layout("Knowledge Graph", body, page_class="graph-page") def _render_search(query): From 65916c53f960b027f9fa7968c8df5408c1a2d9b6 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 5 May 2026 23:41:06 -0600 Subject: [PATCH 087/292] Polish Link header layout --- CHANGELOG.md | 1 + serve.py | 40 +++++++++++++++++++++++----------------- tests/test_serve.py | 3 ++- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8e3d2..28bdcc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Tightened README onboarding and release examples around Link's local memory product value. - Simplified onboarding docs and installed instructions around natural agent prompts and the short `link` command instead of path-heavy maintenance commands. - Moved the local UI theme control into a compact header utility above search so it no longer wraps awkwardly in the navigation row. +- Reworked the local UI header into a clean brand/tools row with navigation tabs below it. - Fixed installer MCP setup reporting so failed upgrades no longer masquerade as success by reusing an unrelated older global `link-mcp`. - Fixed project-mode installer output so MCP wiki paths are absolute and next-step hints point at the project wiki instead of `~/link`. - Fixed search/context matching for natural queries against hyphenated page slugs, e.g. `local first software` now finds `local-first-software`. diff --git a/serve.py b/serve.py index 3ebc7e0..cec241c 100644 --- a/serve.py +++ b/serve.py @@ -877,15 +877,14 @@ def _flush_blockquote(): a, p, li, code { overflow-wrap: anywhere; } a:hover { text-decoration: underline; } -header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 24px; - display: flex; align-items: center; justify-content: space-between; gap: 14px; - flex-wrap: wrap; } -header .logo { font-size: 24px; font-weight: bold; letter-spacing: 0; } +header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 24px; } +header .header-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 12px; } +header .logo { font-size: 24px; font-weight: bold; letter-spacing: 0; white-space: nowrap; flex: 0 0 auto; } header .logo a { color: var(--text-strong); text-decoration: none; display: inline-flex; align-items: center; gap: 8px; } header .logo img { width: 28px; height: 28px; border-radius: 7px; flex: none; } header .logo small { font-weight: normal; font-size: 13px; color: var(--subtle); margin-left: 8px; } -header nav { display: flex; gap: 16px; font-size: 14px; font-family: sans-serif; flex-wrap: wrap; min-width: 0; flex: 1 1 360px; align-items: center; } -header .header-tools { display: grid; justify-items: end; gap: 6px; flex: 0 1 180px; min-width: 150px; } +header nav { display: flex; gap: 10px 16px; font-size: 14px; font-family: sans-serif; flex-wrap: wrap; min-width: 0; align-items: center; } +header .header-tools { display: grid; justify-items: end; gap: 7px; flex: 0 0 220px; min-width: 170px; max-width: 42vw; } header form { display: block; width: 100%; } header input { padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 13px; width: 100%; background: var(--surface); color: var(--text); } header .theme-toggle { border: 1px solid var(--border); background: var(--button-bg); color: var(--button-text); @@ -1012,9 +1011,9 @@ def _flush_blockquote(): font-size: 12px; color: var(--faint); font-family: sans-serif; } @media (max-width: 760px) { body { padding: 20px; } - header { align-items: flex-start; } + header .header-top { align-items: flex-start; } header nav { gap: 10px 14px; } - header .header-tools { justify-items: stretch; flex-basis: 100%; } + header .header-tools { justify-items: end; } header .theme-toggle { justify-self: end; } .home-stats { flex-wrap: wrap; gap: 14px 22px; } .product-lanes { grid-template-columns: minmax(0, 1fr); } @@ -1025,6 +1024,11 @@ def _flush_blockquote(): #graph-canvas { min-height: 460px; } .graph-frame.is-fullscreen { padding: 12px; } } +@media (max-width: 560px) { + header .header-top { flex-wrap: wrap; } + header .header-tools { flex-basis: 100%; max-width: none; justify-items: stretch; } + header .theme-toggle { justify-self: end; } +} """ @@ -1166,7 +1170,17 @@ def _flush_blockquote(): def _header_html(): return f"""
    - +
    + +
    + +
    + +
    +
    +
    -
    - -
    - -
    -
    """ diff --git a/tests/test_serve.py b/tests/test_serve.py index f374594..a8c6184 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -104,7 +104,8 @@ def test_css_has_mobile_overflow_guards(self): self.assertIn("html { overflow-x: hidden; background: var(--bg); }", serve.CSS) self.assertIn("overflow-x: hidden; overflow-wrap: anywhere", serve.CSS) self.assertIn("a, p, li, code { overflow-wrap: anywhere; }", serve.CSS) - self.assertIn("header nav { display: flex; gap: 16px;", serve.CSS) + self.assertIn("header .header-top { display: flex;", serve.CSS) + self.assertIn("header nav { display: flex; gap: 10px 16px;", serve.CSS) self.assertIn("flex-wrap: wrap; min-width: 0", serve.CSS) self.assertIn(".memory-grid { grid-template-columns: minmax(0, 1fr); }", serve.CSS) self.assertIn(".memory-actions code, .memory-next code { word-break: break-word; }", serve.CSS) From fa156417efcfdddc8cf11ddacd532db424e9b64f Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 13:03:51 -0600 Subject: [PATCH 088/292] Add first-use Link commands --- CHANGELOG.md | 2 + README.md | 17 +++--- integrations/_shared/scaffold.sh | 2 +- integrations/antigravity/install.sh | 4 +- integrations/claude-code/install.sh | 4 +- integrations/codex/install.sh | 4 +- integrations/copilot/install.sh | 4 +- integrations/cursor/install.sh | 4 +- integrations/kiro/install.sh | 4 +- integrations/vscode/install.sh | 4 +- link.py | 68 +++++++++++++++++++++--- tests/test_installers.py | 3 +- tests/test_link_cli.py | 82 ++++++++++++++++++++++++++++- 13 files changed, 168 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28bdcc4..3375744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link.py status` so the same readiness summary is available before MCP or the local web server is connected. - Added `link.py status --validate` to installer next-step output so new users have one readiness command after setup. - Added a managed `~/.local/bin/link` command for global installs so users can run `link status --validate`, `link query`, and `link brief` without remembering wiki paths. +- Added `link init` to create or repair a normal Link wiki without loading demo content. +- Added `link serve` to start the local web viewer without remembering `serve.py` paths. - Added clearer product framing in the README and local home page for the distinction between source-backed wiki knowledge and explicit agent memory. - Added a wider graph page layout with fullscreen mode so larger wikis can be explored without being squeezed into the reading column. - Added duplicate protection for `remember`/`remember_memory`; strong duplicate memories are refused unless explicitly allowed. diff --git a/README.md b/README.md index 752bbc5..eb221ae 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,7 @@ Try Link with a finished, pre-ingested wiki: git clone https://github.com/gowtham0992/link.git cd link python3 link.py demo -cd link-demo -python3 serve.py +python3 link.py serve link-demo ``` Open: @@ -95,9 +94,9 @@ Open: Then check the demo: ```bash -python3 link.py memory-audit -python3 link.py doctor -python3 link.py ingest-status +python3 link.py memory-audit link-demo +python3 link.py doctor link-demo +python3 link.py ingest-status link-demo ``` The demo includes source pages, concept pages, a memory page, backlinks, search, @@ -288,8 +287,7 @@ You own the files. Agents maintain them. git clone https://github.com/gowtham0992/link.git cd link python3 link.py demo -cd link-demo -python3 serve.py +python3 link.py serve link-demo ``` ### I Want My Agent To Use Link @@ -434,8 +432,7 @@ link verify-mcp View the wiki: ```bash -cd ~/link -python3 serve.py +link serve ``` Obsidian also works: open `~/link/wiki/` as a vault. @@ -523,6 +520,8 @@ repo-local or source checkout, use `python3 link.py ` in that directory | Command | What it does | |---------|-------------| +| `link init [dir]` | Create or repair a normal Link wiki without demo content. | +| `link serve [dir] [--port 3000]` | Start the local web viewer for a Link wiki. | | `link status [--validate]` | Show local readiness, page/memory counts, optional validation summary, and next actions. | | `link ingest-status` | Show pending raw files and graph index status. | | `link remember "text" [--project slug]` | Save a local agent memory; strong duplicates and likely conflicts are refused unless explicitly allowed. | diff --git a/integrations/_shared/scaffold.sh b/integrations/_shared/scaffold.sh index 9fd576c..dfd95ac 100755 --- a/integrations/_shared/scaffold.sh +++ b/integrations/_shared/scaffold.sh @@ -111,7 +111,7 @@ if [ "$IS_UPDATE" = false ]; then done if [ ! -f "$TARGET_DIR/wiki/_backlinks.json" ]; then - echo '{}' > "$TARGET_DIR/wiki/_backlinks.json" + printf '{\n "backlinks": {},\n "forward": {}\n}\n' > "$TARGET_DIR/wiki/_backlinks.json" echo " Created wiki/_backlinks.json" fi diff --git a/integrations/antigravity/install.sh b/integrations/antigravity/install.sh index 18269a7..7eb80f9 100755 --- a/integrations/antigravity/install.sh +++ b/integrations/antigravity/install.sh @@ -42,10 +42,10 @@ echo "" echo "Done." if [ "$MODE" = "--project" ]; then echo " Drop sources into raw/ and say 'ingest' to process them." - echo " View wiki: python serve.py" + echo " View wiki: python3 link.py serve" else echo " Drop sources into ~/link/raw/ and say 'ingest' to process them." - echo " View wiki: python ~/link/serve.py" + echo " View wiki: link serve" fi echo "" echo " MCP: add to ~/.gemini/settings.json:" diff --git a/integrations/claude-code/install.sh b/integrations/claude-code/install.sh index 8d906d7..6bc8b38 100755 --- a/integrations/claude-code/install.sh +++ b/integrations/claude-code/install.sh @@ -72,8 +72,8 @@ echo "" echo "Done." if [ "$MODE" = "--project" ]; then echo " Drop sources into raw/ and say 'ingest' to process them." - echo " View wiki: python serve.py" + echo " View wiki: python3 link.py serve" else echo " Drop sources into ~/link/raw/ and say 'ingest' to process them." - echo " View wiki: python ~/link/serve.py" + echo " View wiki: link serve" fi diff --git a/integrations/codex/install.sh b/integrations/codex/install.sh index e2ee093..efd3f58 100755 --- a/integrations/codex/install.sh +++ b/integrations/codex/install.sh @@ -44,10 +44,10 @@ echo "" echo "Done." if [ "$MODE" = "--project" ]; then echo " Drop sources into raw/ and say 'ingest' to process them." - echo " View wiki: python serve.py" + echo " View wiki: python3 link.py serve" else echo " Drop sources into ~/link/raw/ and say 'ingest' to process them." - echo " View wiki: python ~/link/serve.py" + echo " View wiki: link serve" fi echo "" diff --git a/integrations/copilot/install.sh b/integrations/copilot/install.sh index 0824b0f..ce02054 100755 --- a/integrations/copilot/install.sh +++ b/integrations/copilot/install.sh @@ -39,10 +39,10 @@ echo "" echo "Done." if [ "$MODE" = "--project" ]; then echo " Drop sources into raw/ and say 'ingest' to process them." - echo " View wiki: python serve.py" + echo " View wiki: python3 link.py serve" else echo " Drop sources into ~/link/raw/ and say 'ingest' to process them." - echo " View wiki: python ~/link/serve.py" + echo " View wiki: link serve" fi echo "" echo " MCP: add to your Copilot MCP config:" diff --git a/integrations/cursor/install.sh b/integrations/cursor/install.sh index b1d6b7d..9840a14 100755 --- a/integrations/cursor/install.sh +++ b/integrations/cursor/install.sh @@ -80,8 +80,8 @@ echo "" echo "Done." if [ "$MODE" = "--project" ]; then echo " Drop sources into raw/ and say 'ingest' to process them." - echo " View wiki: python serve.py" + echo " View wiki: python3 link.py serve" else echo " Drop sources into ~/link/raw/ and say 'ingest' to process them." - echo " View wiki: python ~/link/serve.py" + echo " View wiki: link serve" fi diff --git a/integrations/kiro/install.sh b/integrations/kiro/install.sh index 3069546..c7f0d11 100755 --- a/integrations/kiro/install.sh +++ b/integrations/kiro/install.sh @@ -57,7 +57,7 @@ PYEOF echo "" echo "Done." echo " Drop sources into ~/link/raw/ and say 'ingest' to process them." - echo " View wiki: python ~/link/serve.py" + echo " View wiki: link serve" elif [ "$MODE" = "--project" ]; then INSTRUCTIONS=$(cat "$SCRIPT_DIR/../_shared/link-instructions-project.md") @@ -71,7 +71,7 @@ elif [ "$MODE" = "--project" ]; then echo "" echo "Done." echo " Drop sources into raw/ and say 'ingest' to process them." - echo " View wiki: python serve.py" + echo " View wiki: python3 link.py serve" else echo "Usage: bash install.sh [--project]" exit 1 diff --git a/integrations/vscode/install.sh b/integrations/vscode/install.sh index 20a3145..c3f9984 100755 --- a/integrations/vscode/install.sh +++ b/integrations/vscode/install.sh @@ -65,10 +65,10 @@ echo "" echo "Done." if [ "$MODE" = "--project" ]; then echo " Drop sources into raw/ and say 'ingest' to process them." - echo " View wiki: python serve.py" + echo " View wiki: python3 link.py serve" else echo " Drop sources into ~/link/raw/ and say 'ingest' to process them." - echo " View wiki: python ~/link/serve.py" + echo " View wiki: link serve" fi echo "" echo " MCP: add to .vscode/mcp.json:" diff --git a/link.py b/link.py index a57f2df..642e989 100644 --- a/link.py +++ b/link.py @@ -2,6 +2,8 @@ """Small Link command runner. Usage: + python link.py init [target] + python link.py serve [target] python link.py demo [target] python link.py status [target] python link.py doctor [target] @@ -31,6 +33,7 @@ import fnmatch import json import re +import shlex import shutil import subprocess import sys @@ -2945,27 +2948,66 @@ def verify_mcp( print(" ~/.link-mcp-venv/bin/python -m pip install --upgrade pip link-mcp") print(" Then rerun with: python3 link.py verify-mcp . --python ~/.link-mcp-venv/bin/python") if not wiki_exists: - print(" Create a wiki with an installer, or try: python3 link.py demo") + print(" Create a wiki with an installer, or try: python3 link.py init") print("") print("Result: needs attention") return 1 def _copy_runtime_files(target: Path) -> None: + target.mkdir(parents=True, exist_ok=True) for name in ("serve.py", "link.py", "LINK.md", ".linkignore"): src = ROOT / name - if src.exists(): - shutil.copy2(src, target / name) + dst = target / name + if src.exists() and src.resolve() != dst.resolve(): + shutil.copy2(src, dst) core_src = ROOT / "mcp_package" / "link_core" + if not core_src.exists(): + core_src = ROOT / "link_core" if core_src.exists(): core_target = target / "link_core" core_target.mkdir(exist_ok=True) for src in core_src.glob("*.py"): - shutil.copy2(src, core_target / src.name) + dst = core_target / src.name + if src.resolve() != dst.resolve(): + shutil.copy2(src, dst) for name in ("logo.png", "logo.svg"): src = ROOT / name - if src.exists(): - shutil.copy2(src, target / name) + dst = target / name + if src.exists() and src.resolve() != dst.resolve(): + shutil.copy2(src, dst) + + +def init_wiki(target: Path) -> int: + target = target.expanduser().resolve() + target.mkdir(parents=True, exist_ok=True) + _copy_runtime_files(target) + fixes = _apply_doctor_fixes(target) + + print(f"Link wiki ready at {target}") + if fixes: + print("") + print("Initialized:") + for item in fixes: + print(f" - {item}") + print("") + print("Next:") + print(" link status --validate") + print(" link serve") + print(" Drop sources into raw/ and ask your agent: ingest raw/ into Link") + return 0 + + +def serve_wiki(target: Path, port: int = 3000) -> int: + target = target.expanduser().resolve() + serve_path = target / "serve.py" + if not serve_path.exists(): + print(f"Link viewer missing: {serve_path}") + print("") + print("Next:") + print(f" link init {shlex.quote(str(target))}") + return 1 + return subprocess.run([sys.executable, str(serve_path), "--port", str(port)]).returncode def create_demo(target: Path, force: bool = False) -> None: @@ -3012,8 +3054,7 @@ def create_demo(target: Path, force: bool = False) -> None: print(f"Link demo created at {target}") print("") print("View it:") - print(f" cd {target}") - print(" python3 serve.py") + print(f" python3 link.py serve {shlex.quote(str(target))}") print("") print("Then open:") print(" http://localhost:3000") @@ -3024,6 +3065,13 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="link.py", description="Link command runner") sub = parser.add_subparsers(dest="command", required=True) + init_cmd = sub.add_parser("init", help="create or repair a normal Link wiki") + init_cmd.add_argument("target", nargs="?", default=".") + + serve_cmd = sub.add_parser("serve", help="start the local Link web viewer") + serve_cmd.add_argument("target", nargs="?", default=".") + serve_cmd.add_argument("--port", type=int, default=3000) + demo = sub.add_parser("demo", help="create a pre-ingested sample Link wiki") demo.add_argument("target", nargs="?", default=DEFAULT_DEMO_DIR) demo.add_argument("--force", action="store_true", help="replace an existing Link demo directory") @@ -3192,6 +3240,10 @@ def main(argv: list[str] | None = None) -> int: verify_mcp_cmd.add_argument("--python", default=None, help="Python executable to verify") args = parser.parse_args(argv) + if args.command == "init": + return init_wiki(Path(args.target)) + if args.command == "serve": + return serve_wiki(Path(args.target), port=args.port) if args.command == "demo": create_demo(Path(args.target), force=args.force) return 0 diff --git a/tests/test_installers.py b/tests/test_installers.py index 485e007..0bac1da 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -53,7 +53,8 @@ def test_installers_print_mode_specific_next_steps(self): with self.subTest(installer=installer.name): text = installer.read_text(encoding="utf-8") self.assertIn('if [ "$MODE" = "--project" ]; then', text) - self.assertIn("View wiki: python serve.py", text) + self.assertIn("View wiki: python3 link.py serve", text) + self.assertIn("View wiki: link serve", text) def test_codex_and_kiro_update_existing_mcp_registration(self): codex = (ROOT / "integrations/codex/install.sh").read_text(encoding="utf-8") diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 452b9e9..9a24818 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -1,10 +1,12 @@ import importlib.util import json +import sys import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from io import StringIO from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] @@ -20,6 +22,84 @@ def create_demo_quiet(target: Path, force: bool = False) -> None: class LinkCliTests(unittest.TestCase): + def test_init_creates_empty_wiki(self): + tmp = Path(tempfile.mkdtemp(prefix="link-init-test-")) + target = tmp / "my-link" + + out = StringIO() + with redirect_stdout(out): + code = link_cli.init_wiki(target) + + self.assertEqual(code, 0) + self.assertTrue((target / "serve.py").exists()) + self.assertTrue((target / "link.py").exists()) + self.assertTrue((target / "LINK.md").exists()) + self.assertTrue((target / "link_core/frontmatter.py").exists()) + self.assertTrue((target / "raw").is_dir()) + self.assertTrue((target / "wiki/index.md").exists()) + self.assertTrue((target / "wiki/log.md").exists()) + self.assertTrue((target / "wiki/_backlinks.json").exists()) + self.assertTrue((target / "wiki/sources").is_dir()) + self.assertTrue((target / "wiki/memories").is_dir()) + + backlinks = json.loads((target / "wiki/_backlinks.json").read_text(encoding="utf-8")) + self.assertIn("backlinks", backlinks) + self.assertIn("forward", backlinks) + self.assertIn("link status --validate", out.getvalue()) + self.assertIn("link serve", out.getvalue()) + + def test_init_preserves_existing_pages(self): + tmp = Path(tempfile.mkdtemp(prefix="link-init-test-")) + target = tmp / "my-link" + page = target / "wiki/concepts/custom.md" + page.parent.mkdir(parents=True) + page.write_text("# Custom\n", encoding="utf-8") + + with redirect_stdout(StringIO()): + code = link_cli.init_wiki(target) + + self.assertEqual(code, 0) + self.assertEqual(page.read_text(encoding="utf-8"), "# Custom\n") + + def test_init_copies_core_from_installed_runtime_layout(self): + tmp = Path(tempfile.mkdtemp(prefix="link-init-test-")) + runtime = tmp / "runtime" + runtime.mkdir() + for name in ("serve.py", "link.py", "LINK.md", ".linkignore"): + (runtime / name).write_text(f"# {name}\n", encoding="utf-8") + (runtime / "link_core").mkdir() + (runtime / "link_core/frontmatter.py").write_text("# core\n", encoding="utf-8") + target = tmp / "my-link" + + with patch.object(link_cli, "ROOT", runtime), redirect_stdout(StringIO()): + code = link_cli.init_wiki(target) + + self.assertEqual(code, 0) + self.assertTrue((target / "link_core/frontmatter.py").exists()) + + def test_serve_runs_target_viewer(self): + tmp = Path(tempfile.mkdtemp(prefix="link-serve-test-")) + target = tmp / "demo" + create_demo_quiet(target) + + with patch.object(link_cli.subprocess, "run") as run: + run.return_value.returncode = 0 + code = link_cli.serve_wiki(target, port=3010) + + self.assertEqual(code, 0) + run.assert_called_once_with([sys.executable, str(target.resolve() / "serve.py"), "--port", "3010"]) + + def test_serve_reports_missing_viewer(self): + tmp = Path(tempfile.mkdtemp(prefix="link-serve-test-")) + + out = StringIO() + with redirect_stdout(out): + code = link_cli.serve_wiki(tmp / "missing") + + self.assertEqual(code, 1) + self.assertIn("Link viewer missing", out.getvalue()) + self.assertIn("link init", out.getvalue()) + def test_demo_creates_preingested_wiki(self): tmp = Path(tempfile.mkdtemp(prefix="link-demo-test-")) target = tmp / "demo" @@ -1226,7 +1306,7 @@ def test_verify_mcp_reports_missing_wiki(self): self.assertEqual(code, 1) self.assertIn("Wiki: missing", out.getvalue()) - self.assertIn("python3 link.py demo", out.getvalue()) + self.assertIn("python3 link.py init", out.getvalue()) def test_doctor_reports_dead_links(self): tmp = Path(tempfile.mkdtemp(prefix="link-doctor-test-")) From e8006d2dbe3239faf9ceeb3338b629d0f1a90d08 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 13:07:14 -0600 Subject: [PATCH 089/292] Guide Link ingest next steps --- CHANGELOG.md | 1 + README.md | 5 ++- link.py | 94 +++++++++++++++++++++++++++++++++++------- tests/test_link_cli.py | 9 +++- 4 files changed, 92 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3375744..801e797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added a managed `~/.local/bin/link` command for global installs so users can run `link status --validate`, `link query`, and `link brief` without remembering wiki paths. - Added `link init` to create or repair a normal Link wiki without loading demo content. - Added `link serve` to start the local web viewer without remembering `serve.py` paths. +- Added guided `link ingest-status` output with structured JSON guidance, exact agent prompts, and follow-up validation commands. - Added clearer product framing in the README and local home page for the distinction between source-backed wiki knowledge and explicit agent memory. - Added a wider graph page layout with fullscreen mode so larger wikis can be explored without being squeezed into the reading column. - Added duplicate protection for `remember`/`remember_memory`; strong duplicate memories are refused unless explicitly allowed. diff --git a/README.md b/README.md index eb221ae..bbf4565 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,9 @@ Check what is pending: link ingest-status ``` +`link ingest-status` prints the exact agent prompt to use for the next raw file +and the follow-up checks to run after ingest. + ### 3. Save One Direct Memory You can ask your agent naturally: @@ -523,7 +526,7 @@ repo-local or source checkout, use `python3 link.py ` in that directory | `link init [dir]` | Create or repair a normal Link wiki without demo content. | | `link serve [dir] [--port 3000]` | Start the local web viewer for a Link wiki. | | `link status [--validate]` | Show local readiness, page/memory counts, optional validation summary, and next actions. | -| `link ingest-status` | Show pending raw files and graph index status. | +| `link ingest-status` | Show pending raw files, graph index status, the next agent prompt, and follow-up checks. | | `link remember "text" [--project slug]` | Save a local agent memory; strong duplicates and likely conflicts are refused unless explicitly allowed. | | `link propose-memories [--project slug]` | Propose durable memories from notes without writing them. | | `link capture-session [--project slug]` | Save chat/session notes under `raw/memory-captures/` and return proposal-only memory candidates. | diff --git a/link.py b/link.py index 642e989..b3a5faa 100644 --- a/link.py +++ b/link.py @@ -1126,7 +1126,7 @@ def _collect_ingest_status(target: Path) -> dict[str, object]: else ("missing", "missing wiki directory") ) - return { + payload: dict[str, object] = { "target": str(target), "raw_count": len(raw_files), "source_page_count": len(source_texts), @@ -1139,6 +1139,72 @@ def _collect_ingest_status(target: Path) -> dict[str, object]: "has_raw_dir": raw_dir.exists(), "has_wiki_dir": wiki_dir.exists(), } + payload["guidance"] = _build_ingest_guidance(payload) + return payload + + +def _build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: + has_raw_dir = bool(status.get("has_raw_dir")) + has_wiki_dir = bool(status.get("has_wiki_dir")) + pending_raw = status.get("pending_raw") + pending_items = pending_raw if isinstance(pending_raw, list) else [] + pending_count = int(status.get("pending_count") or 0) + raw_count = int(status.get("raw_count") or 0) + backlinks_status = str(status.get("backlinks_status") or "unknown") + + if not has_raw_dir or not has_wiki_dir: + return { + "state": "missing_structure", + "summary": "Link is not initialized here yet.", + "agent_prompt": None, + "commands": ["link init", "link status --validate"], + "notes": ["Run the installer or initialize this directory before ingesting sources."], + } + + if pending_items: + first = str(pending_items[0].get("raw", "raw/")) + more = pending_count - 1 + summary = f"{pending_count} raw file needs ingest." + if pending_count != 1: + summary = f"{pending_count} raw files need ingest." + if more > 0: + summary += f" Start with {first}; {more} more remain." + return { + "state": "pending_raw", + "summary": summary, + "agent_prompt": f"ingest {first} into Link", + "commands": ["link validate", "link doctor", "link status --validate"], + "notes": [ + "If the source contains user preferences, decisions, or project context, ask for memory proposals before saving durable memories.", + "After ingest, rebuild backlinks if your agent did not already do it.", + ], + } + + if backlinks_status != "current": + return { + "state": "stale_graph", + "summary": "Raw files are represented, but the graph index needs repair.", + "agent_prompt": "rebuild Link backlinks and validate the wiki", + "commands": ["link rebuild-backlinks", "link validate", "link doctor"], + "notes": ["Run the graph repair before relying on context or graph views."], + } + + if raw_count == 0: + return { + "state": "empty", + "summary": "Link is ready, but raw/ has no source files yet.", + "agent_prompt": None, + "commands": ["link status --validate", "link serve"], + "notes": ["Drop notes, articles, transcripts, or project files into raw/, then ask your agent to ingest them into Link."], + } + + return { + "state": "ready", + "summary": "All raw files are represented in wiki/sources and the graph index is current.", + "agent_prompt": None, + "commands": ["link doctor", "link status --validate"], + "notes": ["Add new files to raw/ when you want Link to learn new source-backed knowledge."], + } def _find_pages_missing_summaries(wiki_dir: Path) -> list[str]: @@ -1533,7 +1599,7 @@ def ingest_status(target: Path, json_output: bool = False) -> int: if not status["has_raw_dir"] or not status["has_wiki_dir"]: print("") print("Next:") - print(" Run an installer or create a demo: python3 link.py demo") + print(" Run an installer or initialize this directory: link init") return 1 print(f"Raw files: {status['raw_count']}") @@ -1541,6 +1607,9 @@ def ingest_status(target: Path, json_output: bool = False) -> int: print(f"Represented in wiki/sources: {status['represented_count']}") print(f"Pending ingest: {status['pending_count']}") print(f"Backlinks: {status['backlinks_status']} ({status['backlinks_message']})") + guidance = status["guidance"] + if isinstance(guidance, dict): + print(f"Guidance: {guidance['summary']}") pending_raw = status["pending_raw"] if pending_raw: @@ -1553,18 +1622,15 @@ def ingest_status(target: Path, json_output: bool = False) -> int: print("") print("Next:") - if pending_raw: - first_pending = pending_raw[0]["raw"] - print(f" Ask your agent: ingest {first_pending}") - print(" After ingest: python3 link.py rebuild-backlinks .") - print(" Then validate: python3 link.py validate .") - print(" Then check: python3 link.py doctor .") - elif status["backlinks_status"] != "current": - print(" Repair graph index: python3 link.py rebuild-backlinks .") - print(" Then validate: python3 link.py validate .") - print(" Then check: python3 link.py doctor .") - else: - print(" No pending raw files. Run: python3 link.py doctor .") + if isinstance(guidance, dict): + agent_prompt = guidance.get("agent_prompt") + if agent_prompt: + print(f" Ask your agent: {agent_prompt}") + for command in guidance.get("commands", []): + print(f" Run: {command}") + notes = guidance.get("notes") or [] + for note in notes[:2]: + print(f" Note: {note}") return 0 diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 9a24818..d38e6fb 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -216,7 +216,9 @@ def test_ingest_status_reports_pending_raw_file(self): self.assertEqual(code, 0) self.assertIn("Pending ingest: 1", out.getvalue()) self.assertIn("raw/new-source.md", out.getvalue()) - self.assertIn("Ask your agent: ingest raw/new-source.md", out.getvalue()) + self.assertIn("Guidance: 1 raw file needs ingest.", out.getvalue()) + self.assertIn("Ask your agent: ingest raw/new-source.md into Link", out.getvalue()) + self.assertIn("Run: link validate", out.getvalue()) def test_ingest_status_json(self): tmp = Path(tempfile.mkdtemp(prefix="link-ingest-test-")) @@ -233,6 +235,8 @@ def test_ingest_status_json(self): self.assertEqual(data["raw_count"], 4) self.assertEqual(data["pending_count"], 1) self.assertEqual(data["pending_raw"][0]["raw"], "raw/new-source.md") + self.assertEqual(data["guidance"]["state"], "pending_raw") + self.assertEqual(data["guidance"]["agent_prompt"], "ingest raw/new-source.md into Link") def test_ingest_status_reports_stale_backlinks(self): tmp = Path(tempfile.mkdtemp(prefix="link-ingest-test-")) @@ -247,7 +251,8 @@ def test_ingest_status_reports_stale_backlinks(self): self.assertEqual(code, 0) self.assertIn("Backlinks: stale", out.getvalue()) - self.assertIn("Repair graph index", out.getvalue()) + self.assertIn("Guidance: Raw files are represented, but the graph index needs repair.", out.getvalue()) + self.assertIn("Run: link rebuild-backlinks", out.getvalue()) def test_status_reports_demo_readiness(self): tmp = Path(tempfile.mkdtemp(prefix="link-status-test-")) From a9b419468dba45579efa784dee76bdf08705d407 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 13:13:59 -0600 Subject: [PATCH 090/292] Add read-only memory proposal UI --- CHANGELOG.md | 1 + README.md | 7 +- serve.py | 155 +++++++++++++++++++++++++++++++++++++++++++- tests/test_serve.py | 13 ++++ 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 801e797..70780fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added memory action commands to web inbox and explanation pages, including review, update, archive, restore, and low-priority forget actions. - Added Memory Review Inbox with `memory-inbox`, `review-memory`, MCP `memory_inbox`/`review_memory`, `/inbox`, and `/api/memory-inbox`. - Added Explain Memory views with `explain-memory`, MCP `explain_memory`, `/explain-memory`, and `/api/explain-memory` for provenance, review state, lifecycle, graph links, and recall readiness. +- Added `/propose`, a read-only local UI for turning pasted source/session notes into memory proposals without writing pages. - Added MCP `link_status` and `/api/status` for a compact readiness summary with version, wiki path, page/memory counts, optional validation, and safe next actions. - Added `link.py status` so the same readiness summary is available before MCP or the local web server is connected. - Added `link.py status --validate` to installer next-step output so new users have one readiness command after setup. diff --git a/README.md b/README.md index bbf4565..4d334d6 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ propose memories from raw/file.md Then approve only the memories you want agents to carry forward. +You can also open `/propose`, paste source or session notes, and review +proposal-only candidates in the local UI. Nothing is written until you approve a +memory through your agent or the CLI. + ## Quick Start Try Link with a finished, pre-ingested wiki: @@ -86,6 +90,7 @@ Open: - `http://localhost:3000` - `http://localhost:3000/brief` +- `http://localhost:3000/propose` - `http://localhost:3000/memory` - `http://localhost:3000/audit` - `http://localhost:3000/captures` @@ -507,7 +512,7 @@ Common endpoints: | `GET /api/explain-memory?memory=` | Provenance, lifecycle, graph links, review state, and recall readiness. | | `GET /api/query-link?q=&budget=small\|medium\|large` | Compact context packet with relevant memory, ranked wiki results, graph context, budget reports, follow-up actions, and selection reasons. | | `GET /api/validate?strict=true` | Validate generated wiki pages; failed gates return HTTP 422 with structured findings. | -| `POST /api/propose-memories` | Returns memory proposals without writing pages. | +| `POST /api/propose-memories` | Returns memory proposals without writing pages; used by `/propose`. | | `POST /api/review-memory` | Header `X-Link-Local-Action: true`; JSON `{ "memory": "name", "note": "optional" }`; marks a memory reviewed. | | `POST /api/archive-memory` | Header `X-Link-Local-Action: true`; JSON `{ "memory": "name", "reason": "optional" }`; hides a memory from default recall. | | `POST /api/restore-memory` | Header `X-Link-Local-Action: true`; JSON `{ "memory": "name" }`; restores an archived memory to active recall. | diff --git a/serve.py b/serve.py index cec241c..21b822c 100644 --- a/serve.py +++ b/serve.py @@ -245,13 +245,19 @@ def _memory_duplicate_candidates( ) -def _propose_memories_from_text(text: str, source: str = "http", limit: int = 10) -> dict[str, object]: +def _propose_memories_from_text( + text: str, + source: str = "http", + limit: int = 10, + project: str | None = None, +) -> dict[str, object]: return _core_propose_memories_from_text( text, _memory_records(), source=source, limit=limit, writes_memory=False, + project=project, ) @@ -940,6 +946,23 @@ def _flush_blockquote(): .brief-form button { border: 1px solid var(--border); background: var(--button-bg); color: var(--button-text); border-radius: 4px; padding: 6px 10px; cursor: pointer; } .brief-form button:hover { background: var(--button-hover); } +.proposal-form { display: grid; gap: 10px; margin: 16px 0; font-family: sans-serif; } +.proposal-form textarea, +.proposal-form input { width: 100%; min-width: 0; padding: 8px 9px; border: 1px solid var(--border); + border-radius: 4px; background: var(--surface); color: var(--text); font: inherit; } +.proposal-form textarea { min-height: 190px; resize: vertical; line-height: 1.45; } +.proposal-controls { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) 92px auto; gap: 8px; align-items: end; } +.proposal-form label { display: grid; gap: 4px; color: var(--muted); font-size: 12px; } +.proposal-form button { border: 1px solid var(--border); background: var(--button-bg); color: var(--button-text); + border-radius: 4px; padding: 8px 10px; cursor: pointer; font: inherit; } +.proposal-form button:hover { background: var(--button-hover); } +.proposal-status { min-height: 1.4em; color: var(--muted); font-family: sans-serif; } +.proposal-results { display: grid; gap: 12px; margin-top: 14px; } +.proposal-card { border: 1px solid var(--border-soft); border-radius: 6px; padding: 12px; background: var(--surface); min-width: 0; } +.proposal-card h3 { margin-top: 0; font-size: 16px; } +.proposal-warning { color: #8a6d3b; font-family: sans-serif; font-size: 13px; line-height: 1.45; } +.proposal-command { display: block; margin-top: 10px; padding: 8px; background: var(--surface-code); + border-radius: 4px; white-space: normal; overflow-wrap: anywhere; } .memory-issues { margin-top: 6px; } .memory-issues li { border: none; padding: 0; color: var(--muted); font-size: 13px; } .memory-issues .severity { font-family: sans-serif; font-size: 11px; text-transform: uppercase; color: #8a6d3b; } @@ -1018,6 +1041,7 @@ def _flush_blockquote(): .home-stats { flex-wrap: wrap; gap: 14px 22px; } .product-lanes { grid-template-columns: minmax(0, 1fr); } .memory-grid { grid-template-columns: minmax(0, 1fr); } + .proposal-controls { grid-template-columns: minmax(0, 1fr); } .memory-dashboard .section-heading { flex-wrap: wrap; } .memory-actions code, .memory-next code { word-break: break-word; } .graph-shell { grid-template-columns: 1fr; } @@ -1168,6 +1192,105 @@ def _flush_blockquote(): """ +PROPOSAL_UI_JS = """ +(function() { + var form = document.querySelector('[data-proposal-form]'); + if (!form) return; + var statusEl = document.querySelector('[data-proposal-status]'); + var resultsEl = document.querySelector('[data-proposal-results]'); + + function setStatus(text) { + if (statusEl) statusEl.textContent = text || ''; + } + + function addText(parent, tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + node.textContent = text || ''; + parent.appendChild(node); + return node; + } + + function candidateNames(items) { + return (items || []).map(function(item) { + return item.name || item.title || ''; + }).filter(Boolean).join(', '); + } + + function approvalPrompt(proposal) { + var memory = proposal.memory || ''; + if (proposal.suggested_action === 'update-memory' && proposal.duplicate_candidates && proposal.duplicate_candidates.length) { + var target = proposal.duplicate_candidates[0].name || proposal.duplicate_candidates[0].title || ''; + return 'Approve by asking: update memory ' + target + ' with "' + memory + '"'; + } + return 'Approve by asking: remember that ' + memory; + } + + function renderProposals(data) { + if (!resultsEl) return; + resultsEl.textContent = ''; + if (!data || data.error) { + addText(resultsEl, 'p', 'summary', data && data.error ? data.error : 'No response.'); + return; + } + if (!data.proposals || !data.proposals.length) { + addText(resultsEl, 'p', 'summary', 'No durable memory candidates found. Keep this as source-backed wiki knowledge unless there is a clear preference, decision, or project fact.'); + return; + } + data.proposals.forEach(function(proposal) { + var card = document.createElement('article'); + card.className = 'proposal-card'; + addText(card, 'h3', '', proposal.title || 'Memory proposal'); + addText(card, 'div', 'memory-meta', [ + proposal.memory_type || 'note', + proposal.scope || 'user', + proposal.confidence || 'unknown confidence', + proposal.suggested_action || 'remember' + ].filter(Boolean).join(' · ')); + addText(card, 'p', 'summary', proposal.memory || ''); + if (proposal.reason) addText(card, 'p', 'summary', proposal.reason); + var duplicates = candidateNames(proposal.duplicate_candidates); + if (duplicates) addText(card, 'p', 'proposal-warning', 'Possible duplicate: ' + duplicates); + var conflicts = candidateNames(proposal.conflict_candidates); + if (conflicts) addText(card, 'p', 'proposal-warning', 'Possible conflict: ' + conflicts); + var prompt = addText(card, 'code', 'proposal-command', approvalPrompt(proposal)); + prompt.setAttribute('title', 'Copy this into your agent chat if you approve the memory.'); + resultsEl.appendChild(card); + }); + } + + form.addEventListener('submit', async function(event) { + event.preventDefault(); + var text = form.elements.text.value || ''; + if (!text.trim()) { + setStatus('Paste source or session notes first.'); + return; + } + setStatus('Proposing memories...'); + if (resultsEl) resultsEl.textContent = ''; + try { + var response = await fetch('/api/propose-memories', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + text: text, + source: form.elements.source.value || 'web proposal', + project: form.elements.project.value || '', + limit: form.elements.limit.value || '10' + }) + }); + var data = await response.json(); + if (!response.ok) throw new Error(data.error || 'proposal failed'); + setStatus(data.count + ' proposal' + (data.count === 1 ? '' : 's') + ' found. Nothing was written.'); + renderProposals(data); + } catch (error) { + setStatus(error.message || 'proposal failed'); + } + }); +})(); +""" + + def _header_html(): return f"""
    @@ -1184,6 +1307,7 @@ def _header_html():
    ' ) + prompts = ( + '
    ' + '

    Try These Prompts

    ' + '

    Ask from Codex, Claude, Cursor, Kiro, or any agent with Link installed.

    ' + '
    ' + 'is Link ready?' + 'brief me from Link before we continue' + 'ingest raw/<file> into Link' + 'remember that I prefer local-first agent memory' + 'query Link for what you know about me' + '
    ' + ) - return _layout("Link", f"

    Link

    Local agent memory. Knowledge compounds here.

    {lanes}{stats}{sections}") + return _layout("Link", f"

    Link

    Local agent memory. Knowledge compounds here.

    {lanes}{prompts}{stats}{sections}") def _render_page(page_path): diff --git a/tests/test_serve.py b/tests/test_serve.py index 2c5726e..f6335a4 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -114,6 +114,17 @@ def test_css_has_mobile_overflow_guards(self): self.assertIn(".memory-grid { grid-template-columns: minmax(0, 1fr); }", serve.CSS) self.assertIn(".memory-actions code, .memory-next code { word-break: break-word; }", serve.CSS) + def test_home_page_shows_first_agent_prompts(self): + self.make_wiki() + + html = serve._render_home() + + self.assertIn("Try These Prompts", html) + self.assertIn("is Link ready?", html) + self.assertIn("brief me from Link before we continue", html) + self.assertIn("ingest raw/<file> into Link", html) + self.assertIn("query Link for what you know about me", html) + def test_css_has_explicit_black_dark_theme(self): self.assertIn(':root[data-theme="dark"]', serve.CSS) self.assertIn("--bg: #000000;", serve.CSS) From 92675912fd0176d572a4d08884cfcc37e351e407 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 19:57:31 -0600 Subject: [PATCH 119/292] Share capture naming core --- link.py | 31 +++-------------------- mcp_package/link_core/capture.py | 42 +++++++++++++++++++++++++++++++- mcp_package/link_mcp/server.py | 33 +++++++------------------ tests/test_capture_core.py | 35 ++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 52 deletions(-) diff --git a/link.py b/link.py index 0134d5c..1ce5997 100644 --- a/link.py +++ b/link.py @@ -129,9 +129,11 @@ list_backups as _core_list_backups, ) from link_core.capture import ( + capture_filename as _core_capture_filename, capture_inbox as _core_capture_inbox, capture_notes_from_markdown as _core_capture_notes_from_markdown, capture_records as _core_capture_records, + capture_title as _core_capture_title, cli_capture_commands as _core_cli_capture_commands, resolve_capture_file as _core_resolve_capture_file, ) @@ -1779,31 +1781,6 @@ def propose_memories( return 0 -def _capture_title(text: str, source: str, title: str | None = None) -> str: - if title and title.strip(): - return " ".join(title.split()) - if source != "inline": - stem = Path(source).stem.replace("-", " ").replace("_", " ").strip() - if stem: - return f"Memory capture: {stem.title()}" - first_line = next((line.strip() for line in text.splitlines() if line.strip()), "Session notes") - words = first_line.split() - short = " ".join(words[:10]).strip(" .") - return f"Memory capture: {short or 'Session notes'}" - - -def _capture_filename(timestamp: str, title: str, raw_dir: Path) -> Path: - safe_stamp = timestamp.replace("-", "").replace(":", "") - slug = _core_slugify(title.replace("Memory capture:", ""), fallback="session-notes") - base = f"{safe_stamp}-{slug}" - candidate = raw_dir / f"{base}.md" - counter = 2 - while candidate.exists(): - candidate = raw_dir / f"{base}-{counter}.md" - counter += 1 - return candidate - - def capture_session( target: Path, source_input: str, @@ -1826,11 +1803,11 @@ def capture_session( timestamp = _utc_timestamp() project_name = project or _default_project(root) - capture_title = _capture_title(text, source, title) + capture_title = _core_capture_title(text, source, title, default_source="inline", path_source=True) secret_warnings = _secret_value_warnings(text) capture_dir = root / "raw" / "memory-captures" capture_dir.mkdir(parents=True, exist_ok=True) - capture_path = _capture_filename(timestamp, capture_title, capture_dir) + capture_path = _core_capture_filename(timestamp, capture_title, capture_dir) project_line = f'project: "{_frontmatter_string(project_name)}"\n' if project_name else "" capture_path.write_text( f"""--- diff --git a/mcp_package/link_core/capture.py b/mcp_package/link_core/capture.py index 0b8a070..826df5e 100644 --- a/mcp_package/link_core/capture.py +++ b/mcp_package/link_core/capture.py @@ -6,13 +6,53 @@ from typing import Callable from .frontmatter import parse_frontmatter -from .memory import normalize_project +from .memory import normalize_project, slugify from .security import redact_secret_values, secret_value_warnings CaptureCommands = Callable[[str], dict[str, str]] +def capture_title( + text: str, + source: str = "", + title: str | None = None, + *, + default_source: str = "inline", + path_source: bool = False, + max_source_len: int = 120, +) -> str: + """Build a stable human-readable title for saved raw memory captures.""" + if title and title.strip(): + return " ".join(title.split()) + + source_value = " ".join(str(source or "").split()) + if source_value and source_value != default_source: + if path_source: + stem = Path(source_value).stem.replace("-", " ").replace("_", " ").strip() + if stem: + return f"Memory capture: {stem.title()}" + else: + return f"Memory capture: {source_value[:max_source_len]}" + + first_line = next((line.strip() for line in text.splitlines() if line.strip()), "Session notes") + short = " ".join(first_line.split()[:10]).strip(" .") + return f"Memory capture: {short or 'Session notes'}" + + +def capture_filename(timestamp: str, title: str, raw_dir: Path) -> Path: + """Return a unique capture path under raw_dir for the given timestamp/title.""" + safe_stamp = str(timestamp).replace("-", "").replace(":", "") + title_slug = slugify(title.replace("Memory capture:", ""), fallback="session-notes") + base = f"{safe_stamp}-{title_slug}" + candidate = raw_dir / f"{base}.md" + counter = 2 + while candidate.exists(): + candidate = raw_dir / f"{base}-{counter}.md" + counter += 1 + return candidate + + def resolve_capture_file(root: Path, capture: str, *, max_len: int | None = None) -> Path | None: """Resolve a user-provided raw capture path without escaping the Link root.""" raw = str(capture or "").strip() diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 78295ad..957e87d 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -117,9 +117,11 @@ list_backups as _core_list_backups, ) from link_core.capture import ( + capture_filename as _core_capture_filename, capture_inbox as _core_capture_inbox, capture_notes_from_markdown as _core_capture_notes_from_markdown, capture_records as _core_capture_records, + capture_title as _core_capture_title, mcp_capture_commands as _core_mcp_capture_commands, resolve_capture_file as _core_resolve_capture_file, ) @@ -410,28 +412,6 @@ def _propose_memories_from_text( ) -def _capture_title(text: str, source: str, title: str = "") -> str: - if title.strip(): - return " ".join(title.split()) - if source.strip() and source.strip() != "mcp": - return f"Memory capture: {' '.join(source.strip().split())[:120]}" - first_line = next((line.strip() for line in text.splitlines() if line.strip()), "Session notes") - short = " ".join(first_line.split()[:10]).strip(" .") - return f"Memory capture: {short or 'Session notes'}" - - -def _capture_filename(timestamp: str, title: str, raw_dir: Path) -> Path: - safe_stamp = timestamp.replace("-", "").replace(":", "") - slug = _core_slugify(title.replace("Memory capture:", ""), fallback="session-notes") - base = f"{safe_stamp}-{slug}" - candidate = raw_dir / f"{base}.md" - counter = 2 - while candidate.exists(): - candidate = raw_dir / f"{base}-{counter}.md" - counter += 1 - return candidate - - def _capture_session( text: str, title: str = "", @@ -445,12 +425,17 @@ def _capture_session( clean_source = _clean_text_input(source, max_len=500) or "mcp" project_name = _resolve_project(project) timestamp = _utc_timestamp() - capture_title = _capture_title(clean_text, clean_source, _clean_text_input(title, max_len=200)) + capture_title = _core_capture_title( + clean_text, + clean_source, + _clean_text_input(title, max_len=200), + default_source="mcp", + ) secret_warnings = _secret_value_warnings(clean_text) root = WIKI_DIR.parent capture_dir = root / "raw" / "memory-captures" capture_dir.mkdir(parents=True, exist_ok=True) - capture_path = _capture_filename(timestamp, capture_title, capture_dir) + capture_path = _core_capture_filename(timestamp, capture_title, capture_dir) project_line = f'project: "{_frontmatter_string(project_name)}"\n' if project_name else "" capture_path.write_text( f"""--- diff --git a/tests/test_capture_core.py b/tests/test_capture_core.py index d5310d8..4b00a52 100644 --- a/tests/test_capture_core.py +++ b/tests/test_capture_core.py @@ -3,15 +3,50 @@ from pathlib import Path from mcp_package.link_core.capture import ( + capture_filename, capture_inbox, capture_notes_from_markdown, capture_records, + capture_title, mcp_capture_commands, resolve_capture_file, ) class CaptureCoreTests(unittest.TestCase): + def test_capture_title_uses_explicit_title_first(self): + self.assertEqual( + capture_title("ignored", "inline", " Sprint planning notes "), + "Sprint planning notes", + ) + + def test_capture_title_supports_cli_path_sources(self): + self.assertEqual( + capture_title("", "raw/first-memory.md", path_source=True), + "Memory capture: First Memory", + ) + + def test_capture_title_supports_mcp_source_labels(self): + self.assertEqual( + capture_title("", "daily standup", default_source="mcp"), + "Memory capture: daily standup", + ) + + def test_capture_title_falls_back_to_first_note_line(self): + self.assertEqual( + capture_title("\n\nRemember that Link is local agent memory.\nMore detail."), + "Memory capture: Remember that Link is local agent memory", + ) + + def test_capture_filename_is_unique_and_slugged(self): + root = Path(tempfile.mkdtemp(prefix="link-capture-filename-")) + first = capture_filename("2026-05-06T01:02:03Z", "Memory capture: First Memory", root) + first.write_text("# first\n", encoding="utf-8") + second = capture_filename("2026-05-06T01:02:03Z", "Memory capture: First Memory", root) + + self.assertEqual(first.name, "20260506T010203Z-first-memory.md") + self.assertEqual(second.name, "20260506T010203Z-first-memory-2.md") + def test_resolve_capture_file_accepts_supported_root_relative_forms(self): root = Path(tempfile.mkdtemp(prefix="link-capture-core-")) capture_dir = root / "raw" / "memory-captures" From fa66972207e89fc1b3f99ea8a5c7deb23bb85823 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:00:22 -0600 Subject: [PATCH 120/292] Show demo proof of value --- README.md | 10 +++++---- link.py | 48 ++++++++++++++++++++++++++++++++++++++++++ tests/test_link_cli.py | 4 ++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b4e7610..66c5619 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,15 @@ Open: Then check the demo: ```bash +python3 link.py query "why does Link help agents?" link-demo --budget small +python3 link.py brief "working on agent memory" link-demo python3 link.py memory-audit link-demo -python3 link.py doctor link-demo -python3 link.py ingest-status link-demo +python3 link.py status --validate link-demo ``` -The demo includes source pages, concept pages, a memory page, backlinks, search, -a graph view, memory audit, and MCP-ready retrieval. +The first query should return a compact packet with a relevant memory, the best +wiki page, nearby graph context, and agent guidance. The demo also writes +`link-demo/START_HERE.md` with the exact prompts and checks to try. ## What You Get diff --git a/link.py b/link.py index 1ce5997..71974e6 100644 --- a/link.py +++ b/link.py @@ -176,6 +176,46 @@ DEMO_FILES: dict[str, str] = { + "START_HERE.md": """# Link Demo: Start Here + +This demo is already ingested. It shows the full loop: source notes, wiki pages, +agent memory, backlinks, graph context, and a compact query packet. + +## Try These Agent Prompts + +```text +is Link ready? +query Link for why Link helps agents +brief me from Link before we continue +what does Link remember about local personal memory? +explain why Link remembers local personal memory +``` + +## Try These CLI Checks + +```bash +python3 link.py query "why does Link help agents?" . --budget small +python3 link.py brief "working on agent memory" . +python3 link.py memory-audit . +python3 link.py status --validate . +``` + +## What To Look For + +- The query packet includes both memory and source-backed wiki context. +- The packet is budget-limited, so agents do not need to read the whole wiki. +- The memory entry is inspectable under `wiki/memories/`. +- The graph view shows how sources, concepts, memories, and explorations connect. + +Open the local viewer: + +```bash +python3 link.py serve . +``` + +Then visit `http://localhost:3000`, `http://localhost:3000/brief`, and +`http://localhost:3000/graph`. +""", "raw/agent-memory-session.md": """--- title: "Agent memory session" source_type: demo-note @@ -3023,6 +3063,14 @@ def create_demo(target: Path, force: bool = False) -> None: print("View it:") print(f" python3 link.py serve {shlex.quote(str(target))}") print("") + print("Try the value loop:") + print(f" python3 link.py query \"why does Link help agents?\" {shlex.quote(str(target))} --budget small") + print(f" python3 link.py brief \"working on agent memory\" {shlex.quote(str(target))}") + print(f" python3 link.py memory-audit {shlex.quote(str(target))}") + print("") + print("Guide:") + print(f" {target / 'START_HERE.md'}") + print("") print("Then open:") print(" http://localhost:3000") print(" http://localhost:3000/graph") diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 59085db..11fa8c1 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -114,10 +114,14 @@ def test_demo_creates_preingested_wiki(self): self.assertTrue((target / "link_core/frontmatter.py").exists()) self.assertTrue((target / "link_core/memory.py").exists()) self.assertTrue((target / "LINK.md").exists()) + self.assertTrue((target / "START_HERE.md").exists()) self.assertTrue((target / "raw/agent-memory-session.md").exists()) self.assertTrue((target / "wiki/concepts/agent-memory.md").exists()) self.assertTrue((target / "wiki/entities/link.md").exists()) self.assertTrue((target / "wiki/_link_schema.json").exists()) + guide = (target / "START_HERE.md").read_text(encoding="utf-8") + self.assertIn("query Link for why Link helps agents", guide) + self.assertIn('python3 link.py query "why does Link help agents?" . --budget small', guide) backlinks = json.loads((target / "wiki/_backlinks.json").read_text(encoding="utf-8")) self.assertIn("backlinks", backlinks) From 0517e4caaa387877b625b759aaadb765773282e2 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:04:10 -0600 Subject: [PATCH 121/292] Polish README onboarding flow --- README.md | 102 +++++++++++++++++++++++++++--------------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 66c5619..3141a7c 100644 --- a/README.md +++ b/README.md @@ -39,46 +39,10 @@ project constraints, and why something matters. Link makes that context durable: - **Local-first:** no hosted backend, no telemetry, no cloud lock-in. - **Inspectable:** Markdown files, backlinks, logs, and review states are yours. -## Sources, Wiki, And Memory - -Link has one simple rule: - -```text -Sources become wiki knowledge. -Explicit "remember" becomes agent memory. -Queries use both. -``` - -That means raw files do not silently personalize future agents. When you add a -source and ask Link to ingest it, Link creates source-backed wiki pages, updates -concept/entity pages, rebuilds backlinks, and validates the graph. When you say -`remember ...`, Link saves a durable memory that future agents may use as user -or project context. - -Use these three moves: - -```text -ingest raw/file.md into Link -remember that I prefer short release notes -query Link for the release process -``` - -If a source might contain preferences or decisions, ask for proposals first: - -```text -propose memories from raw/file.md -``` - -Then approve only the memories you want agents to carry forward. - -You can also open `/propose`, paste source or session notes, or jump there from -a pending raw file on `/ingest`. Link loads that source into the proposal form -and returns proposal-only candidates. Nothing is written until you approve a -memory through your agent or the CLI. - ## Quick Start -Try Link with a finished, pre-ingested wiki: +Start with the finished demo. It already has raw sources, wiki pages, one local +memory, backlinks, graph data, and a query packet ready to inspect. ```bash git clone https://github.com/gowtham0992/link.git @@ -110,6 +74,15 @@ The first query should return a compact packet with a relevant memory, the best wiki page, nearby graph context, and agent guidance. The demo also writes `link-demo/START_HERE.md` with the exact prompts and checks to try. +After the demo: + +| Goal | Go to | +|------|-------| +| Use Link with your agent | [First 10 Minutes](#first-10-minutes) | +| Understand the storage model | [Sources, Wiki, And Memory](#sources-wiki-and-memory) | +| Configure only MCP | [I Want MCP Only](#i-want-mcp-only) | +| Contribute to Link | [Contributing](#contributing) | + ## What You Get ### Wiki Home @@ -163,6 +136,41 @@ source or log evidence supports it. Link Explain Memory view in dark mode

    +## Sources, Wiki, And Memory + +Link has one simple rule: + +```text +Sources become wiki knowledge. +Explicit "remember" becomes agent memory. +Queries use both. +``` + +Raw files do not silently personalize future agents. When you drop a source into +`raw/` and ask Link to ingest it, Link creates source-backed wiki pages, updates +concept/entity pages, rebuilds backlinks, and validates the graph. When you say +`remember ...`, Link saves durable memory that future agents may use as user or +project context. + +Use these three moves: + +```text +ingest raw/file.md into Link +remember that I prefer short release notes +query Link for the release process +``` + +If a source might contain preferences or decisions, ask for proposals first: + +```text +propose memories from raw/file.md +``` + +Then approve only the memories you want agents to carry forward. You can also +open `/propose`, paste notes, or jump there from a pending raw file on `/ingest`. +Link returns proposal-only candidates. Nothing is written until you approve a +memory through your agent or the CLI. + ## First 10 Minutes This path turns one real note into local agent memory. @@ -361,20 +369,11 @@ Then use that Python in your MCP config: } ``` -### I Want To Develop Link - -```bash -python3 -m unittest discover -s tests -python3 scripts/smoke_first_use.py -python3 scripts/smoke_large_wiki.py --pages 1000 -python3 scripts/check_release_hygiene.py -python3 scripts/check_runtime_duplication.py -python3 scripts/check_tool_contract.py -git diff --check -``` - ## Daily Workflow +Most days, you only need three habits: add sources, save explicit memories, and +ask Link for a brief before important work. + Add source material: ```bash @@ -670,6 +669,7 @@ link/ - Confidence tags make uncertainty visible. - `log.md` records wiki operations. - Pages mature from seed to established. -- Agents should use MCP `get_context` or `/api/context` before reading files manually. +- Agents should use `query_link` first, then follow up with graph/context tools + only when the compact packet is insufficient. - The local web viewer has no runtime dependencies beyond Python stdlib. - The wiki is plain Markdown, so it works with git, Obsidian, and normal editors. From f86ae1bc69fe1b598acfeef3fcec4ab6b25f4d57 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:09:48 -0600 Subject: [PATCH 122/292] Clarify ingest next step --- serve.py | 31 +++++++++++++++++++++++++++++++ tests/test_serve.py | 3 +++ 2 files changed, 34 insertions(+) diff --git a/serve.py b/serve.py index 8267eea..3be57b4 100644 --- a/serve.py +++ b/serve.py @@ -2088,6 +2088,7 @@ def _render_ingest(): ingest_prompt = agent_prompt or f"ingest {first_raw} into Link" memory_prompt = str(plan.get("memory_prompt") or f"propose memories from {first_raw}") propose_href = "/propose?source=" + urllib.parse.quote(first_raw) if pending else "/propose" + state = str(guidance.get("state") or plan.get("state") or "unknown") stats = ( f'
    ' @@ -2109,6 +2110,35 @@ def _render_ingest(): f'{html.escape(str(command))}
    ' ) actions = f'
    {action_rows}
    ' if action_rows else "" + if agent_prompt: + next_detail = "Copy this into your agent chat. The agent should ingest the raw source, rebuild indexes, and validate before reporting done." + next_code = agent_prompt + next_extra = ( + f'

    If the source contains preferences, decisions, or project facts, ' + f'open memory proposals first.

    ' + ) + elif state == "stale_graph": + next_detail = "Repair the graph index before relying on search, context, or the graph view." + next_code = "link rebuild-backlinks && link validate" + next_extra = "" + elif state == "empty": + next_detail = "Add a note, article, transcript, or project file to raw/, then refresh this page." + next_code = "cp notes.md raw/ && link ingest-status" + next_extra = "" + elif state == "ready": + next_detail = "No ingest is pending. Ask Link for context, or add another source when there is new material." + next_code = 'link brief "current task"' + next_extra = "" + else: + next_detail = "Initialize or repair the Link folder before ingesting sources." + next_code = "link init && link status --validate" + next_extra = "" + next_html = ( + f'
    Next step' + f'

    {html.escape(next_detail)}

    ' + f'{html.escape(next_code)}' + f'{next_extra}
    ' + ) guide_html = ( f'
    ' f'
    1' @@ -2179,6 +2209,7 @@ def _render_ingest(): f'

    Ingest

    ' f'

    {html.escape(str(guidance.get("summary") or "Check raw source ingest state."))}

    ' f'{stats}' + f'{next_html}' f'{guide_html}' f'{actions}' f'{plan_html}' diff --git a/tests/test_serve.py b/tests/test_serve.py index f6335a4..4f72105 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -949,7 +949,10 @@ def test_ingest_page_and_api_show_pending_raw(self): self.assertEqual(payload["pending_count"], 1) self.assertEqual(payload["guidance"]["state"], "pending_raw") self.assertEqual(payload["plan"]["batch"][0]["suggested_source_page"], "wiki/sources/new-source.md") + self.assertIn("Next step", html) + self.assertIn("Copy this into your agent chat", html) self.assertIn("ingest raw/new-source.md into Link", html) + self.assertIn("open memory proposals first", html) self.assertIn("Ingest path", html) self.assertIn("Optional memory", html) self.assertIn("propose memories from raw/new-source.md", html) From 5ae9a3643fdd3c092b2de8a2f65917f9499c4315 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:13:01 -0600 Subject: [PATCH 123/292] Clarify memory proposal approval --- serve.py | 17 +++++++++++++++++ tests/test_serve.py | 3 +++ 2 files changed, 20 insertions(+) diff --git a/serve.py b/serve.py index 3be57b4..d27bbe1 100644 --- a/serve.py +++ b/serve.py @@ -2050,12 +2050,29 @@ def _render_captures(project: str | None = None): def _render_propose(project: str | None = None, source: str | None = None): project_value = html.escape(str(project or ""), quote=True) source_value = html.escape(str(source or ""), quote=True) + proposal_path = ( + f'
    ' + f'
    1' + f'

    Load source

    Paste notes or load a safe local raw file. The source stays local.

    ' + f'raw/file.md
    ' + f'
    2' + f'

    Propose

    Link returns candidates only. This step never writes durable memory.

    ' + f'Propose
    ' + f'
    3' + f'

    Approve explicitly

    Copy the approval prompt into your agent chat only for memories you want kept.

    ' + f'remember that ...
    ' + f'
    4' + f'

    Review later

    Use the inbox and explain views to review, archive, update, or forget memories.

    ' + f'link memory-inbox
    ' + f'
    ' + ) body = ( f'' f'

    Propose Memories

    ' f'

    Paste source notes, session notes, or a raw excerpt. Link returns memory candidates without writing anything.

    ' f'
    Trust rule' f'

    Source-backed wiki knowledge and durable agent memory are separate. Save only preferences, decisions, or project facts you approve.

    ' + f'{proposal_path}' f'

    Local Raw Sources

    captures
    ' f'
    ' f'
    ' diff --git a/tests/test_serve.py b/tests/test_serve.py index 4f72105..45c0b14 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -893,6 +893,9 @@ def test_propose_page_renders_read_only_workflow(self): self.assertIn('value="link"', html) self.assertIn("without writing anything", html) self.assertIn("Save only preferences", html) + self.assertIn("Memory proposal path", html) + self.assertIn("Approve explicitly", html) + self.assertIn("This step never writes durable memory", html) self.assertIn("Proposal-only: no durable memory has been written yet.", html) self.assertIn("Copy approval prompt", html) self.assertIn("navigator.clipboard.writeText", html) From 0a23b8e6cdc5ac09e9218bf8d8f2d01ff6d549e9 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:16:47 -0600 Subject: [PATCH 124/292] Add graph neighborhood focus --- serve.py | 16 +++++++++++++++- tests/test_serve.py | 3 +++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/serve.py b/serve.py index d27bbe1..f38bd8f 100644 --- a/serve.py +++ b/serve.py @@ -2419,6 +2419,7 @@ def _render_graph(): var inspectorMeta = document.getElementById('graph-inspector-meta'); var inspectorLinks = document.getElementById('graph-inspector-links'); var inspectorOpen = document.getElementById('graph-open'); + var inspectorFocus = document.getElementById('graph-focus'); var W, H; // Compact neural-map sizing: concepts lead, sources recede. @@ -2585,12 +2586,13 @@ def _render_graph(): }} function updateInspector() {{ - if (!inspector || !inspectorTitle || !inspectorMeta || !inspectorLinks || !inspectorOpen) return; + if (!inspector || !inspectorTitle || !inspectorMeta || !inspectorLinks || !inspectorOpen || !inspectorFocus) return; inspectorLinks.textContent = ''; if (!selectedNode) {{ inspectorTitle.textContent = 'Select a node'; inspectorMeta.textContent = 'Click a node to inspect it. Drag a node to place it. Double-click a node, or use Open page, to navigate.'; inspectorOpen.disabled = true; + inspectorFocus.disabled = true; return; }} var neighbors = (adj[selectedNode.id] || []).slice().sort(function(a, b) {{ @@ -2599,6 +2601,7 @@ def _render_graph(): inspectorTitle.textContent = selectedNode.title; inspectorMeta.textContent = selectedNode.category + ' · ' + neighbors.length + ' linked page' + (neighbors.length === 1 ? '' : 's'); inspectorOpen.disabled = false; + inspectorFocus.disabled = false; neighbors.slice(0, 10).forEach(function(id) {{ var target = nodeById[id]; var link = document.createElement('a'); @@ -3009,6 +3012,16 @@ def _render_graph(): setFullscreen(!frameEl.classList.contains('is-fullscreen')); }}); if (inspectorOpen) inspectorOpen.addEventListener('click', function() {{ openNode(selectedNode); }}); + if (inspectorFocus) inspectorFocus.addEventListener('click', function() {{ + if (!selectedNode) return; + depthValue = '1'; + if (depthFilter) depthFilter.value = '1'; + invalidateFilters(); + setMotionPaused(motionPaused); + autoFit(); + updateStatus(); + drawSoon(); + }}); if (searchInput) {{ searchInput.addEventListener('input', function() {{ searchTerm = searchInput.value.trim().toLowerCase(); @@ -3084,6 +3097,7 @@ def _render_graph(): f'

    Click a node to inspect it. Drag a node to place it. ' f'Double-click a node, or use Open page, to navigate.

    ' f'' + f'' f'' f'' f'
    ' diff --git a/tests/test_serve.py b/tests/test_serve.py index 45c0b14..c6b3eb1 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -1095,7 +1095,9 @@ def test_graph_controls_exist_before_graph_script(self): self.assertLess(html.index('id="graph-category"'), html.index("var categoryFilter =")) self.assertLess(html.index('id="graph-depth"'), html.index("var depthFilter =")) self.assertLess(html.index('id="graph-inspector"'), html.index("var inspector =")) + self.assertLess(html.index('id="graph-focus"'), html.index("var inspectorFocus =")) self.assertIn('id="graph-status"', html) + self.assertIn("Focus neighborhood", html) self.assertIn('id="graph-open"', html) self.assertIn('tabindex="0"', html) self.assertIn('role="img"', html) @@ -1103,6 +1105,7 @@ def test_graph_controls_exist_before_graph_script(self): self.assertIn("function visibleNodes()", html) self.assertIn("function visibleEdges()", html) self.assertIn("function syncDepthControl()", html) + self.assertIn("depthValue = '1'", html) self.assertIn("depthFilter.disabled = !selectedNode;", html) self.assertIn("Select a node before filtering by neighborhood.", html) self.assertIn("var LARGE_GRAPH_LIMIT = 350;", html) From 61cff80fc7dd332eb71eee2653936da685be1030 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:20:30 -0600 Subject: [PATCH 125/292] Share text input cleanup --- mcp_package/link_core/security.py | 7 +++++++ mcp_package/link_mcp/server.py | 8 +------- serve.py | 7 +------ tests/test_security_core.py | 27 +++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 13 deletions(-) create mode 100644 tests/test_security_core.py diff --git a/mcp_package/link_core/security.py b/mcp_package/link_core/security.py index 34f1b7a..26f4984 100644 --- a/mcp_package/link_core/security.py +++ b/mcp_package/link_core/security.py @@ -17,6 +17,13 @@ ) +def clean_text_input(value: object, max_len: int = 500) -> str: + """Normalize optional user/tool text input to a stripped, bounded string.""" + if value is None: + return "" + return str(value).strip()[:max_len] + + def secret_value_warnings(text: str) -> list[str]: """Return labels for secret-looking values found in text.""" warnings: list[str] = [] diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 957e87d..c500fdc 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -136,6 +136,7 @@ utc_timestamp as _core_utc_timestamp, ) from link_core.security import ( + clean_text_input as _clean_text_input, redact_secret_values as _redact_secret_values, secret_value_warnings as _secret_value_warnings, ) @@ -163,13 +164,6 @@ ) -def _clean_text_input(value, max_len: int = MAX_TEXT_INPUT) -> str: - if value is None: - return "" - text = str(value).strip() - return text[:max_len] - - def _required_text_input(value, message: str, max_len: int = MAX_TEXT_INPUT) -> str: text = _clean_text_input(value, max_len=max_len) if not text: diff --git a/serve.py b/serve.py index f38bd8f..04025df 100644 --- a/serve.py +++ b/serve.py @@ -39,6 +39,7 @@ utc_timestamp as _core_utc_timestamp, ) from link_core.security import ( + clean_text_input as _clean_text_input, redact_secret_values as _redact_secret_values, secret_value_warnings as _secret_value_warnings, ) @@ -177,12 +178,6 @@ def _parse_search_limit(raw: str) -> tuple[int | None, str | None]: return min(limit, 50), None -def _clean_text_input(value, max_len: int = 500) -> str: - if value is None: - return "" - return str(value).strip()[:max_len] - - def _utc_timestamp() -> str: return _core_utc_timestamp() diff --git a/tests/test_security_core.py b/tests/test_security_core.py new file mode 100644 index 0000000..05f5f73 --- /dev/null +++ b/tests/test_security_core.py @@ -0,0 +1,27 @@ +import unittest + +from mcp_package.link_core.security import clean_text_input, redact_secret_values, secret_value_warnings + + +class SecurityCoreTests(unittest.TestCase): + def test_clean_text_input_strips_bounds_and_handles_none(self): + self.assertEqual(clean_text_input(None), "") + self.assertEqual(clean_text_input(" hello "), "hello") + self.assertEqual(clean_text_input(" hello world ", max_len=5), "hello") + self.assertEqual(clean_text_input(123, max_len=2), "12") + + def test_secret_warnings_and_redaction(self): + fake_key = "sk-" + "a" * 48 + + warnings = secret_value_warnings(f"token {fake_key}") + redacted, labels, count = redact_secret_values(f"token {fake_key}") + + self.assertEqual(warnings, ["OpenAI API key"]) + self.assertEqual(labels, ["OpenAI API key"]) + self.assertEqual(count, 1) + self.assertNotIn(fake_key, redacted) + self.assertIn("[redacted-secret]", redacted) + + +if __name__ == "__main__": + unittest.main() From e2e07d9a04f7ab3be12ed98ace253890a8761d68 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:22:17 -0600 Subject: [PATCH 126/292] Validate demo proof path --- scripts/smoke_first_use.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/smoke_first_use.py b/scripts/smoke_first_use.py index 8f0e410..dfae0e4 100644 --- a/scripts/smoke_first_use.py +++ b/scripts/smoke_first_use.py @@ -69,7 +69,15 @@ def run_smoke(work_dir: Path, python: str = sys.executable) -> None: require(init_status.get("ready") is True, "initialized wiki did not report ready") require(init_status.get("schema", {}).get("status") == "current", "initialized wiki schema is not current") - run_link("demo", str(demo_target), "--force", python=python) + demo_result = run_link("demo", str(demo_target), "--force", python=python) + require("Try the value loop:" in demo_result.stdout, "demo output did not show the value loop") + require("query \"why does Link help agents?\"" in demo_result.stdout, "demo output did not show the query proof command") + require("START_HERE.md" in demo_result.stdout, "demo output did not point to START_HERE.md") + require((demo_target / "START_HERE.md").exists(), "demo did not create START_HERE.md") + start_here = (demo_target / "START_HERE.md").read_text(encoding="utf-8") + require("query Link for why Link helps agents" in start_here, "START_HERE.md did not include agent prompt") + require("python3 link.py query" in start_here, "START_HERE.md did not include CLI proof command") + demo_status = run_json("status", str(demo_target), "--validate", "--json", python=python) require(demo_status.get("ready") is True, "demo wiki did not report ready") require(demo_status.get("validation", {}).get("passed") is True, "demo validation did not pass") From e6d895101c79a72d3b35b1754a9917ef551008b0 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:24:33 -0600 Subject: [PATCH 127/292] Clarify MCP package onboarding --- mcp_package/README.md | 76 +++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/mcp_package/README.md b/mcp_package/README.md index 2322106..63ee6b4 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -8,75 +8,87 @@ Listed on the [official MCP Registry](https://registry.modelcontextprotocol.io) Release notes: [CHANGELOG.md](https://github.com/gowtham0992/link/blob/main/CHANGELOG.md) -## Install +## What You Need -```bash -python3 -m pip install --upgrade link-mcp -``` +`link-mcp` is the MCP server. It needs a Link wiki to read from. The normal +wiki location is `~/link/wiki`, created by the main Link installers. -If macOS/Homebrew Python reports `externally-managed-environment`, install into a dedicated venv: +Recommended setup: ```bash -python3 -m venv ~/.link-mcp-venv -~/.link-mcp-venv/bin/python -m pip install --upgrade pip link-mcp +git clone https://github.com/gowtham0992/link.git +bash link/integrations/codex/install.sh # or claude-code, cursor, kiro, vscode ``` -Then use the venv Python in your MCP config: +The installer scaffolds `~/link/`, installs or upgrades `link-mcp`, writes agent +instructions, and prints the exact MCP config for your machine. -```json -{ - "mcpServers": { - "link": { - "command": "/Users/YOU/.link-mcp-venv/bin/python", - "args": ["-m", "link_mcp", "--wiki", "/Users/YOU/link/wiki"] - } - } -} +After install, ask your agent: + +```text +is Link ready? +brief me from Link before we continue +query Link for what you know about this project ``` -Replace `/Users/YOU` with your absolute home path. +## MCP-Only Install -## Quick setup (Kiro) +Use this when you already have a Link wiki and only need the MCP package. ```bash -git clone https://github.com/gowtham0992/link.git -bash link/integrations/kiro/install.sh +python3 -m pip install --upgrade link-mcp ``` -This installs `link-mcp`, scaffolds `~/link/`, and registers the MCP server in `~/.kiro/settings/mcp.json` automatically. - -## Manual setup (any MCP client) +If macOS/Homebrew Python reports `externally-managed-environment`, use a +dedicated venv: -1. Scaffold your wiki: ```bash -git clone https://github.com/gowtham0992/link.git -bash link/integrations/kiro/install.sh # or claude-code, cursor, codex +python3 -m venv ~/.link-mcp-venv +~/.link-mcp-venv/bin/python -m pip install --upgrade pip link-mcp ``` -2. Add to your MCP client config: +Then add the server to your MCP client config. Use an absolute wiki path: + ```json { "mcpServers": { "link": { "command": "python3", - "args": ["-m", "link_mcp"] + "args": ["-m", "link_mcp", "--wiki", "/Users/YOU/link/wiki"] } } } ``` -Custom wiki path: +If you installed into the venv, use the venv Python: + ```json { "mcpServers": { "link": { - "command": "python3", - "args": ["-m", "link_mcp", "--wiki", "~/my-wiki/wiki"] + "command": "/Users/YOU/.link-mcp-venv/bin/python", + "args": ["-m", "link_mcp", "--wiki", "/Users/YOU/link/wiki"] } } } ``` +Replace `/Users/YOU` with your absolute home path. The default wiki is +`~/link/wiki/`; override with `--wiki /path/to/wiki`. + +## Agent Workflow + +Most agents should call: + +1. `link_status(include_validation=true)` when connecting or troubleshooting. +2. `memory_brief(query="")` before personalized or project work. +3. `query_link(query="", budget="small")` for compact answer-ready context. +4. `ingest_status()` when the user drops files into `raw/`. +5. `validate_wiki(strict=true)` after ingest or large edits. + +Use `remember_memory` only when the user explicitly approves saving durable +memory. Use `propose_memories` or `capture_session` for proposal-only review. + ## Tools | Tool | Description | From 55bc180612397a5e14c313181feae638ebe0e438 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:26:49 -0600 Subject: [PATCH 128/292] Block tracked build artifacts --- scripts/check_release_hygiene.py | 28 +++++++++++++++++++++++++--- tests/test_release_hygiene.py | 19 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/scripts/check_release_hygiene.py b/scripts/check_release_hygiene.py index 4099f4d..3e54977 100644 --- a/scripts/check_release_hygiene.py +++ b/scripts/check_release_hygiene.py @@ -30,6 +30,15 @@ "service-account*.json", ) +BUILD_ARTIFACT_PATTERNS = ( + "dist/*", + "*/dist/*", + "*.whl", + "*.tar.gz", + "*.egg-info", + "*.egg-info/*", +) + SECRET_VALUE_PATTERNS = ( ("Anthropic API key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b")), ("OpenAI API key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")), @@ -144,6 +153,21 @@ def check_agent_contract( findings.append(f"agent contract missing {term!r} in {path}") +def check_tracked_path_hygiene(findings: list[str], path: Path) -> bool: + """Check release-blocking tracked path patterns. Return true when caller should skip content scan.""" + rel = path.as_posix() + if any(fnmatch.fnmatch(rel, pattern) for pattern in BUILD_ARTIFACT_PATTERNS): + findings.append(f"build artifact should not be tracked: {path}") + return True + + name = path.name + if any(fnmatch.fnmatch(name, pattern) for pattern in SECRET_NAME_PATTERNS): + findings.append(f"sensitive-looking tracked filename: {path}") + return True + + return False + + def main() -> int: findings: list[str] = [] current_version = check_version_consistency(findings) @@ -151,9 +175,7 @@ def main() -> int: check_agent_contract(findings) for path in tracked_files(): - name = path.name - if any(fnmatch.fnmatch(name, pattern) for pattern in SECRET_NAME_PATTERNS): - findings.append(f"sensitive-looking tracked filename: {path}") + if check_tracked_path_hygiene(findings, path): continue if path.suffix.lower() in BINARY_SUFFIXES: diff --git a/tests/test_release_hygiene.py b/tests/test_release_hygiene.py index 5032c74..8f483a1 100644 --- a/tests/test_release_hygiene.py +++ b/tests/test_release_hygiene.py @@ -73,6 +73,25 @@ def test_agent_contract_requires_status_query_validate_and_brief_terms(self): self.assertIn(f"agent contract missing 'memory_brief' in {bad}", findings) self.assertIn(f"agent contract file missing: {tmp / 'missing.md'}", findings) + def test_tracked_path_hygiene_blocks_build_artifacts_and_secret_names(self): + findings: list[str] = [] + + skip_wheel = release_hygiene.check_tracked_path_hygiene( + findings, + Path("mcp_package/dist/link_mcp-1.2.0-py3-none-any.whl"), + ) + skip_secret = release_hygiene.check_tracked_path_hygiene(findings, Path(".pypirc")) + skip_normal = release_hygiene.check_tracked_path_hygiene(findings, Path("README.md")) + + self.assertTrue(skip_wheel) + self.assertTrue(skip_secret) + self.assertFalse(skip_normal) + self.assertIn( + "build artifact should not be tracked: mcp_package/dist/link_mcp-1.2.0-py3-none-any.whl", + findings, + ) + self.assertIn("sensitive-looking tracked filename: .pypirc", findings) + if __name__ == "__main__": unittest.main() From 488f5a74ee377409544c9f587fe918f4c5f989b5 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:28:48 -0600 Subject: [PATCH 129/292] Clarify integration onboarding --- integrations/README.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/integrations/README.md b/integrations/README.md index e3ed4a6..b86beea 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -1,15 +1,22 @@ -# Integrations +# Link Integrations -One-step setup for your AI tool. Default is global — one central wiki at `~/link/` that works across all projects. +One-step setup for local agents. The default mode creates one central Link wiki +at `~/link/` and teaches your agent how to use it as local personal memory. ## Quick start ```bash git clone https://github.com/gowtham0992/link.git ~/link-repo -bash ~/link-repo/integrations/kiro/install.sh +bash ~/link-repo/integrations/codex/install.sh ``` -That's it. Kiro now knows about Link in every project, and your wiki lives at `~/link/`. +Pick the installer that matches your agent. After install, try: + +```text +is Link ready? +brief me from Link before we continue +query Link for what you know about this project +``` ## All integrations @@ -31,12 +38,17 @@ That's it. Kiro now knows about Link in every project, and your wiki lives at `~ ## What the install does -1. Writes a small instruction file for your tool (so it knows Link exists) -2. Scaffolds wiki structure at `~/link/` (or current dir with `--project`) -3. Installs or upgrades `link-mcp` using normal pip first, then `~/.link-mcp-venv` if system Python is externally managed -4. Adds `~/.local/bin/link` for global installs, so local checks are short: `link status --validate` - -The instruction file is minimal — it just tells the agent that Link exists and to read `LINK.md` when you say "ingest", "query", "lint", or "research". It doesn't interfere with normal coding work. +1. Upserts a small Link instruction block without overwriting your existing instructions. +2. Scaffolds wiki structure at `~/link/` or the current directory with `--project`. +3. Installs or upgrades `link-mcp`, using `~/.link-mcp-venv` when system Python is externally managed. +4. Writes `.link-mcp-python` so clients can use the Python that actually has `link-mcp`. +5. Adds `~/.local/bin/link` for global installs, so checks are short: `link status --validate`. +6. Prints next prompts and verification commands for your install mode. + +The instruction file is intentionally small. It tells the agent to check +`link_status`, use `query_link` for compact context, use `memory_brief` before +personalized/project work, validate after ingest, and read `LINK.md` only when it +needs the full local protocol. ## Uninstall From 80080f839bd96628e4249243f4aa137ef09d22fb Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:31:09 -0600 Subject: [PATCH 130/292] Summarize unreleased release highlights --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 919b051..1f475c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ## [Unreleased] +### Highlights + +- Reframes Link as local personal memory for agents, with the Markdown wiki as the inspectable storage layer. +- Adds the first-use path around `link init`, `link serve`, the managed `link` command, demo proof prompts, and readiness checks. +- Adds the memory lifecycle: remember, recall, propose, capture, approve, review, archive, restore, forget, explain, profile, and audit. +- Adds smart query packets so MCP agents can retrieve budgeted memory, ranked wiki context, graph neighborhoods, and follow-up actions without scanning the whole wiki. +- Adds guided ingest/proposal UI, Memory Dashboard, larger graph controls, dark/light/system themes, and clearer local web navigation. +- Adds schema migration, validation gates, release hygiene, MCP contract checks, runtime duplication guardrails, and broader first-use/large-wiki smoke tests. + ### Added - Added Memory Mode foundation with `wiki/memories/`, `link.py remember`, `link.py recall`, and MCP `remember_memory`/`recall_memory` tools. From 3db108c024467c7d3e52da4618ff717b2053c0b5 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:33:47 -0600 Subject: [PATCH 131/292] Report runtime duplication audit details --- scripts/check_runtime_duplication.py | 65 +++++++++++++++++++++++----- tests/test_runtime_duplication.py | 16 +++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/scripts/check_runtime_duplication.py b/scripts/check_runtime_duplication.py index 0a609c7..f14b7f7 100644 --- a/scripts/check_runtime_duplication.py +++ b/scripts/check_runtime_duplication.py @@ -2,6 +2,7 @@ """Guard against large copied helper bodies across Link runtimes.""" from __future__ import annotations +import argparse import ast import sys from dataclasses import dataclass @@ -81,16 +82,8 @@ def check_exact_duplicate_bodies(functions: list[FunctionInfo]) -> list[str]: def check_large_duplicate_private_names(functions: list[FunctionInfo]) -> list[str]: - by_name: dict[str, list[FunctionInfo]] = {} - for info in functions: - if info.name.startswith("_"): - by_name.setdefault(info.name, []).append(info) - findings: list[str] = [] - for name, group in sorted(by_name.items()): - paths = {info.path for info in group} - if len(paths) < 2: - continue + for name, group in duplicate_private_name_groups(functions): if max(info.line_count for info in group) < LARGE_DUPLICATE_LINE_THRESHOLD: continue if name in ALLOWED_LARGE_DUPLICATE_NAMES: @@ -100,13 +93,65 @@ def check_large_duplicate_private_names(functions: list[FunctionInfo]) -> list[s return findings -def main() -> int: +def duplicate_private_name_groups(functions: list[FunctionInfo]) -> list[tuple[str, list[FunctionInfo]]]: + by_name: dict[str, list[FunctionInfo]] = {} + for info in functions: + if info.name.startswith("_"): + by_name.setdefault(info.name, []).append(info) + + groups: list[tuple[str, list[FunctionInfo]]] = [] + for name, group in sorted(by_name.items()): + paths = {info.path for info in group} + if len(paths) >= 2: + groups.append((name, group)) + return groups + + +def format_private_name_report(functions: list[FunctionInfo]) -> str: + groups = duplicate_private_name_groups(functions) + if not groups: + return "Duplicate private runtime helper names: 0" + + report_rows = [] + for name, group in groups: + max_lines = max(info.line_count for info in group) + total_lines = sum(info.line_count for info in group) + guarded = max_lines >= LARGE_DUPLICATE_LINE_THRESHOLD + locations = ", ".join(info.location for info in sorted(group, key=lambda item: item.location)) + report_rows.append((guarded, max_lines, total_lines, name, locations)) + + report_rows.sort(key=lambda row: (not row[0], -row[1], row[3])) + guarded_count = sum(1 for guarded, *_ in report_rows if guarded) + lines = [ + "Duplicate private runtime helper names: " + f"{len(report_rows)} ({guarded_count} at or above {LARGE_DUPLICATE_LINE_THRESHOLD} lines)" + ] + for guarded, max_lines, total_lines, name, locations in report_rows: + status = "guarded" if guarded else "thin" + lines.append(f"- {name}: {status}; max {max_lines} lines, total {total_lines}; {locations}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--report", + action="store_true", + help="print a non-failing audit of duplicate private helper names before running the guard", + ) + args = parser.parse_args(argv) + functions = runtime_functions() + if args.report: + print(format_private_name_report(functions)) + findings = [ *check_exact_duplicate_bodies(functions), *check_large_duplicate_private_names(functions), ] if findings: + if args.report: + print("") print("Runtime duplication guard failed:") for finding in findings: print(f"- {finding}") diff --git a/tests/test_runtime_duplication.py b/tests/test_runtime_duplication.py index cf4c5f8..722cd6b 100644 --- a/tests/test_runtime_duplication.py +++ b/tests/test_runtime_duplication.py @@ -54,6 +54,22 @@ def test_exact_duplicate_body_is_reported_even_with_different_names(self): self.assertTrue(any("exact duplicate" in finding for finding in findings)) + def test_report_tracks_thin_duplicate_private_helpers_without_failing(self): + tmp = Path(tempfile.mkdtemp(prefix="link-runtime-dup-test-")) + a = tmp / "a.py" + b = tmp / "b.py" + a.write_text("def _adapter():\n return 1\n", encoding="utf-8") + b.write_text("def _adapter():\n return 2\n", encoding="utf-8") + + functions = runtime_duplication.runtime_functions((a, b)) + + report = runtime_duplication.format_private_name_report(functions) + findings = runtime_duplication.check_large_duplicate_private_names(functions) + + self.assertIn("_adapter", report) + self.assertIn("thin", report) + self.assertEqual(findings, []) + if __name__ == "__main__": unittest.main() From 7564f04cb31a66c72d2c0bb5ccae788bb5c4b6a2 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:36:18 -0600 Subject: [PATCH 132/292] Harden release publish commands --- scripts/prepare_release.py | 3 ++- tests/test_prepare_release.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index 819175b..27d2690 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -187,7 +187,8 @@ def release_commands(version: str) -> list[str]: 'python3 -c "from pathlib import Path; import shutil; shutil.rmtree(\'dist\', ignore_errors=True); [shutil.rmtree(p, ignore_errors=True) for p in Path(\'.\').glob(\'*.egg-info\')]"', "python3 -m build", "python3 -m twine check dist/*", - "TWINE_USERNAME=__token__ python3 -m twine upload dist/*", + f"TWINE_USERNAME=__token__ python3 -m twine upload dist/link_mcp-{version}*", + "mcp-publisher validate", "mcp-publisher publish", ] diff --git a/tests/test_prepare_release.py b/tests/test_prepare_release.py index 1d0ac0a..71a301d 100644 --- a/tests/test_prepare_release.py +++ b/tests/test_prepare_release.py @@ -120,6 +120,8 @@ def test_release_commands_use_version_tag(self): self.assertIn('git tag -a v1.0.6 -m "v1.0.6"', commands) self.assertTrue(any("glob('*.egg-info')" in command for command in commands)) + self.assertIn("TWINE_USERNAME=__token__ python3 -m twine upload dist/link_mcp-1.0.6*", commands) + self.assertIn("mcp-publisher validate", commands) self.assertIn("mcp-publisher publish", commands) From 8d0e3f1f6473ac743416c0b1deb19099c2cbe836 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:38:40 -0600 Subject: [PATCH 133/292] Share default project resolution --- link.py | 6 ++---- mcp_package/link_core/memory.py | 9 +++++++++ mcp_package/link_mcp/server.py | 6 ++---- tests/test_memory_core.py | 12 ++++++++++++ 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/link.py b/link.py index 71974e6..9439a3f 100644 --- a/link.py +++ b/link.py @@ -104,6 +104,7 @@ from link_core.memory import ( add_capture_review_to_brief as _core_add_capture_review_to_brief, count_values as _core_count_values, + default_project_for_target as _core_default_project_for_target, forget_memory_page as _core_forget_memory_page, mark_memory_reviewed as _core_mark_memory_reviewed, memory_brief as _core_memory_brief, @@ -807,10 +808,7 @@ def _resolve_link_root(target: Path) -> Path: def _default_project(target: Path) -> str: - root = _resolve_link_root(target) - if (root / ".git").exists(): - return _core_slugify(root.name, fallback="") - return "" + return _core_default_project_for_target(target) def _utc_timestamp() -> str: diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 17d43f3..ea7293b 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -107,6 +107,15 @@ def normalize_project(value: str | None) -> str: return slugify(value or "", fallback="") +def default_project_for_target(target: Path) -> str: + resolved = target.expanduser().resolve() + if resolved.name == "wiki" and (resolved / "index.md").exists(): + resolved = resolved.parent + if (resolved / ".git").exists(): + return normalize_project(resolved.name) + return "" + + def memory_title(text: str, explicit_title: str | None = None) -> str: if explicit_title and explicit_title.strip(): return explicit_title.strip() diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index c500fdc..d54a3f9 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -91,6 +91,7 @@ from link_core.memory import ( add_capture_review_to_brief as _core_add_capture_review_to_brief, count_values as _core_count_values, + default_project_for_target as _core_default_project_for_target, forget_memory_page as _core_forget_memory_page, mark_memory_reviewed as _core_mark_memory_reviewed, memory_brief as _core_memory_brief, @@ -180,10 +181,7 @@ def _parse_limit(value, default: int = 20, max_limit: int = 50) -> int: def _default_project() -> str: - root = WIKI_DIR.parent - if (root / ".git").exists(): - return _core_slugify(root.name, fallback="") - return "" + return _core_default_project_for_target(WIKI_DIR) def _wiki_mtime() -> float: diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 3a039be..10471e7 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -9,6 +9,7 @@ from link_core.memory import ( # noqa: E402 add_capture_review_to_brief, + default_project_for_target, extract_wikilinks, forget_memory_page, mark_memory_reviewed, @@ -31,6 +32,17 @@ class MemoryCoreTests(unittest.TestCase): + def test_default_project_for_target_uses_git_root_name(self): + root = Path(tempfile.mkdtemp(prefix="link-memory-project-")) / "Link Product" + wiki = root / "wiki" + wiki.mkdir(parents=True) + (root / ".git").mkdir() + (wiki / "index.md").write_text("# Index\n", encoding="utf-8") + + self.assertEqual(default_project_for_target(root), "link-product") + self.assertEqual(default_project_for_target(wiki), "link-product") + self.assertEqual(default_project_for_target(Path(tempfile.mkdtemp(prefix="link-no-project-"))), "") + def test_memory_records_profile_and_recall(self): root = Path(tempfile.mkdtemp(prefix="link-memory-core-")) wiki = root / "wiki" From 72853a45037cbb9506f4c5d56057a99b8f6fdcb7 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:39:56 -0600 Subject: [PATCH 134/292] Align README validation gate with CI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3141a7c..0c16ba7 100644 --- a/README.md +++ b/README.md @@ -617,7 +617,7 @@ Before opening a PR, run the local gate: ```bash python3 -m unittest discover -s tests -python3 -m py_compile link.py serve.py scripts/check_release_hygiene.py scripts/check_runtime_duplication.py scripts/check_tool_contract.py scripts/prepare_release.py scripts/smoke_first_use.py scripts/smoke_large_wiki.py scripts/smoke_mcp_stdio.py mcp_package/link_core/*.py mcp_package/link_mcp/server.py +python3 -m py_compile link.py serve.py scripts/check_release_hygiene.py scripts/check_runtime_duplication.py scripts/check_tool_contract.py scripts/prepare_release.py scripts/smoke_first_use.py scripts/smoke_large_wiki.py scripts/smoke_mcp_stdio.py mcp_package/link_core/*.py mcp_package/link_mcp/server.py mcp_package/link_mcp/__main__.py mcp_package/link_mcp/__init__.py python3 scripts/smoke_first_use.py python3 scripts/smoke_large_wiki.py --pages 1000 python3 scripts/check_release_hygiene.py From 4dc251cda5646365b2b97e26e5fb3dd3ac94a53b Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:44:01 -0600 Subject: [PATCH 135/292] Enforce large wiki smoke timings --- scripts/smoke_large_wiki.py | 35 ++++++++++++++++++++++++++++++++-- tests/test_large_wiki_smoke.py | 15 +++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/scripts/smoke_large_wiki.py b/scripts/smoke_large_wiki.py index ad94c4f..a56a93f 100644 --- a/scripts/smoke_large_wiki.py +++ b/scripts/smoke_large_wiki.py @@ -17,6 +17,13 @@ from link_core.query import query_link # noqa: E402 from link_core.wiki import build_backlinks, build_wiki_cache, graph_data, search_pages # noqa: E402 +DEFAULT_MAX_SECONDS = { + "cache": 5.0, + "search": 2.0, + "query": 5.0, + "graph": 3.0, +} + class SmokeFailure(RuntimeError): pass @@ -109,7 +116,18 @@ def timed(label: str, fn): return label, value, elapsed -def run_smoke(work_dir: Path, page_count: int) -> dict[str, object]: +def check_timing_thresholds(timings: dict[str, float], max_seconds: dict[str, float]) -> None: + for label, elapsed in sorted(timings.items()): + ceiling = max_seconds.get(label) + if ceiling is None: + continue + require( + elapsed <= ceiling, + f"{label} path took {elapsed:.4f}s, above {ceiling:.4f}s threshold", + ) + + +def run_smoke(work_dir: Path, page_count: int, max_seconds: dict[str, float] | None = None) -> dict[str, object]: wiki = build_large_wiki(work_dir, page_count) timings: dict[str, float] = {} @@ -141,6 +159,8 @@ def run_smoke(work_dir: Path, page_count: int) -> dict[str, object]: require(packet.get("follow_up", [{}])[0].get("tool") == "query_link", "query did not return follow-up guidance") require(len(graph["nodes"]) == expected_pages, f"expected {expected_pages} graph nodes, got {len(graph['nodes'])}") require(len(graph["edges"]) >= page_count * 2, "graph edge count is unexpectedly low") + max_seconds = max_seconds or DEFAULT_MAX_SECONDS + check_timing_thresholds(timings, max_seconds) return { "wiki": str(wiki), @@ -149,6 +169,7 @@ def run_smoke(work_dir: Path, page_count: int) -> dict[str, object]: "context_items": len(packet.get("context_packet", [])), "search_results": len(results), "timings": {key: round(value, 4) for key, value in timings.items()}, + "max_seconds": max_seconds, } @@ -156,6 +177,10 @@ def main() -> int: parser = argparse.ArgumentParser(description="Smoke test Link against a synthetic large wiki.") parser.add_argument("--pages", type=int, default=1000, help="number of synthetic concept pages") parser.add_argument("--work-dir", default="", help="directory for generated wiki artifacts") + parser.add_argument("--max-cache-seconds", type=float, default=DEFAULT_MAX_SECONDS["cache"]) + parser.add_argument("--max-search-seconds", type=float, default=DEFAULT_MAX_SECONDS["search"]) + parser.add_argument("--max-query-seconds", type=float, default=DEFAULT_MAX_SECONDS["query"]) + parser.add_argument("--max-graph-seconds", type=float, default=DEFAULT_MAX_SECONDS["graph"]) args = parser.parse_args() if args.pages < 1: @@ -163,8 +188,14 @@ def main() -> int: return 2 work_dir = Path(args.work_dir).expanduser().resolve() if args.work_dir else Path(tempfile.mkdtemp(prefix="link-large-wiki-")) + max_seconds = { + "cache": args.max_cache_seconds, + "search": args.max_search_seconds, + "query": args.max_query_seconds, + "graph": args.max_graph_seconds, + } try: - payload = run_smoke(work_dir, args.pages) + payload = run_smoke(work_dir, args.pages, max_seconds=max_seconds) except SmokeFailure as exc: print(f"Large-wiki smoke failed: {exc}", file=sys.stderr) return 1 diff --git a/tests/test_large_wiki_smoke.py b/tests/test_large_wiki_smoke.py index 5042e61..54728b5 100644 --- a/tests/test_large_wiki_smoke.py +++ b/tests/test_large_wiki_smoke.py @@ -1,4 +1,5 @@ import json +import importlib.util import sys import tempfile import unittest @@ -12,6 +13,14 @@ from link_core.query import query_link # noqa: E402 from link_core.wiki import build_backlinks, build_wiki_cache, graph_data # noqa: E402 +SPEC = importlib.util.spec_from_file_location( + "smoke_large_wiki", ROOT / "scripts/smoke_large_wiki.py" +) +smoke_large_wiki = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = smoke_large_wiki +SPEC.loader.exec_module(smoke_large_wiki) + def write_page(wiki: Path, rel: str, text: str) -> None: path = wiki / rel @@ -106,6 +115,12 @@ def test_smart_query_and_graph_handle_hundreds_of_pages(self): self.assertEqual(len(graph["nodes"]), page_count + 30) self.assertGreaterEqual(len(graph["edges"]), page_count) + def test_large_wiki_smoke_enforces_timing_thresholds(self): + smoke_large_wiki.check_timing_thresholds({"query": 0.01}, {"query": 0.02}) + + with self.assertRaisesRegex(smoke_large_wiki.SmokeFailure, "above 0.0200s threshold"): + smoke_large_wiki.check_timing_thresholds({"query": 0.03}, {"query": 0.02}) + if __name__ == "__main__": unittest.main() From 99217c018517353984ddf293814b915b25d7b901 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:45:15 -0600 Subject: [PATCH 136/292] Document large wiki timing guard --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f475c9..fbbfb6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added a real MCP stdio smoke test for the built `link-mcp` wheel in CI. - Added a first-use smoke test for init, demo, status, query, brief, remember, capture, ingest-status, and validation workflows. - Added large-wiki smoke coverage for smart query budgets and graph generation across hundreds of pages. +- Added timing thresholds to large-wiki smoke coverage so major search/query/graph performance regressions fail early. - Added release hygiene checks that protect the public agent instruction contract for `query_link`, `validate_wiki`, and `memory_brief`. - Updated agent contract checks and installed instructions to include `link_status` for setup/readiness checks. - Changed CI to run on pull requests and manual dispatch only, preserving GitHub minutes for the develop-branch workflow. From 06d912d49aa0b7ef5686508372eedd3e3cb6a37d Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:48:16 -0600 Subject: [PATCH 137/292] Require local action header for rebuild APIs --- CHANGELOG.md | 1 + README.md | 7 +++--- serve.py | 11 ++++++--- tests/test_serve.py | 60 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbbfb6a..f423db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Fixed project-mode installer output so MCP wiki paths are absolute and next-step hints point at the project wiki instead of `~/link`. - Fixed search/context matching for natural queries against hyphenated page slugs, e.g. `local first software` now finds `local-first-software`. - Hardened backlink rebuild over HTTP so local web rebuilds require JSON POST instead of a mutating GET. +- Hardened HTTP rebuild actions so local web index/backlink mutations require the explicit local-action header. - Hardened `/raw/` static serving so the local web viewer only serves supported media/PDF source assets. - Tightened raw asset path resolution so `/raw/` URLs cannot route through non-raw static allowlists, including encoded parent-directory paths. - Hardened HTTP memory mutation endpoints with an explicit `X-Link-Local-Action: true` header required by non-UI clients. diff --git a/README.md b/README.md index 0c16ba7..b466f68 100644 --- a/README.md +++ b/README.md @@ -506,8 +506,7 @@ from other explicit projects out of recall and duplicate/conflict checks. `serve.py` exposes Link locally while the web viewer is running. Local use only: `serve.py` binds to `127.0.0.1` and has no authentication. Do not -expose it to the internet without adding auth. HTTP memory writes are limited to -review, archive, and restore actions, and require +expose it to the internet without adding auth. HTTP write actions require `X-Link-Local-Action: true`; proposal analysis does not write pages. Common endpoints: @@ -535,8 +534,8 @@ Common endpoints: | `GET /api/search?q=` | Ranked search by title, alias, tag, TLDR, and full text. | | `GET /api/context?topic=` | Best matching page plus inbound and forward graph links. | | `GET /api/graph` | Nodes and edges for graph visualization. | -| `POST /api/rebuild-backlinks` | Rebuild `_backlinks.json` by scanning wikilinks. | -| `POST /api/rebuild-index` | Regenerate `wiki/index.md` from current pages. | +| `POST /api/rebuild-backlinks` | Header `X-Link-Local-Action: true`; rebuild `_backlinks.json` by scanning wikilinks. | +| `POST /api/rebuild-index` | Header `X-Link-Local-Action: true`; regenerate `wiki/index.md` from current pages. | ## Command Reference diff --git a/serve.py b/serve.py index 04025df..3911e4f 100644 --- a/serve.py +++ b/serve.py @@ -3230,6 +3230,8 @@ def do_POST(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path if path == "/api/rebuild-index": + if not self._require_local_action_header({"rebuilt": False}): + return payload, error, status = self._read_json_body() if error: self._json({"rebuilt": False, "error": error}, status=status) @@ -3238,6 +3240,8 @@ def do_POST(self): self._json(_rebuild_index_payload()) return if path == "/api/rebuild-backlinks": + if not self._require_local_action_header({"rebuilt": False}): + return payload, error, status = self._read_json_body() if error: self._json({"rebuilt": False, "error": error}, status=status) @@ -3507,13 +3511,14 @@ def _json(self, data, status: int = 200): if not getattr(self, '_head_only', False): self.wfile.write(encoded) - def _require_local_action_header(self) -> bool: + def _require_local_action_header(self, error_payload: dict[str, object] | None = None) -> bool: value = self.headers.get(LOCAL_ACTION_HEADER, "").strip().lower() if value in LOCAL_ACTION_VALUES: return True + payload = dict(error_payload or {"updated": False}) + payload["error"] = f"{LOCAL_ACTION_HEADER} header required for local mutations" self._json({ - "updated": False, - "error": f"{LOCAL_ACTION_HEADER} header required for local memory mutations", + **payload, }, status=403) return False diff --git a/tests/test_serve.py b/tests/test_serve.py index c6b3eb1..6a91b37 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -981,23 +981,49 @@ def test_rebuild_backlinks_requires_json_post(self): get_status, get_payload = run_handler("GET", "/api/rebuild-backlinks") bad_post_status, bad_post_payload = run_handler("POST", "/api/rebuild-backlinks") - post_status, post_payload = run_handler( + missing_header_status, missing_header_payload = run_handler( "POST", "/api/rebuild-backlinks", body=b"{}", headers={"Content-Type": "application/json", "Content-Length": "2"}, ) + post_status, post_payload = run_handler( + "POST", + "/api/rebuild-backlinks", + body=b"{}", + headers={ + "Content-Type": "application/json", + "Content-Length": "2", + "X-Link-Local-Action": "true", + }, + ) rebuilt = json.loads(backlinks_path.read_text(encoding="utf-8")) self.assertEqual(get_status, 405) self.assertIn("use POST", get_payload["error"]) - self.assertEqual(bad_post_status, 415) + self.assertEqual(bad_post_status, 403) self.assertFalse(bad_post_payload["rebuilt"]) + self.assertIn("X-Link-Local-Action", bad_post_payload["error"]) + self.assertEqual(missing_header_status, 403) + self.assertFalse(missing_header_payload["rebuilt"]) + self.assertIn("X-Link-Local-Action", missing_header_payload["error"]) self.assertEqual(post_status, 200) self.assertTrue(post_payload["rebuilt"]) self.assertEqual(rebuilt["backlinks"], {"b": ["a"]}) self.assertEqual(rebuilt["forward"], {"a": ["b"]}) + def test_rebuild_backlinks_rejects_bad_json_after_local_header(self): + wiki = self.make_wiki() + + bad_post_status, bad_post_payload = run_handler( + "POST", + "/api/rebuild-backlinks", + headers={"X-Link-Local-Action": "true"}, + ) + + self.assertEqual(bad_post_status, 415) + self.assertFalse(bad_post_payload["rebuilt"]) + def test_rebuild_index_requires_json_post(self): wiki = self.make_wiki() write_page( @@ -1010,23 +1036,49 @@ def test_rebuild_index_requires_json_post(self): get_status, get_payload = run_handler("GET", "/api/rebuild-index") bad_post_status, bad_post_payload = run_handler("POST", "/api/rebuild-index") - post_status, post_payload = run_handler( + missing_header_status, missing_header_payload = run_handler( "POST", "/api/rebuild-index", body=b"{}", headers={"Content-Type": "application/json", "Content-Length": "2"}, ) + post_status, post_payload = run_handler( + "POST", + "/api/rebuild-index", + body=b"{}", + headers={ + "Content-Type": "application/json", + "Content-Length": "2", + "X-Link-Local-Action": "true", + }, + ) index_text = index_path.read_text(encoding="utf-8") self.assertEqual(get_status, 405) self.assertIn("use POST", get_payload["error"]) - self.assertEqual(bad_post_status, 415) + self.assertEqual(bad_post_status, 403) self.assertFalse(bad_post_payload["rebuilt"]) + self.assertIn("X-Link-Local-Action", bad_post_payload["error"]) + self.assertEqual(missing_header_status, 403) + self.assertFalse(missing_header_payload["rebuilt"]) + self.assertIn("X-Link-Local-Action", missing_header_payload["error"]) self.assertEqual(post_status, 200) self.assertTrue(post_payload["rebuilt"]) self.assertIn("[[a]]", index_text) self.assertEqual(post_payload["category_counts"]["concepts"], 1) + def test_rebuild_index_rejects_bad_json_after_local_header(self): + wiki = self.make_wiki() + + bad_post_status, bad_post_payload = run_handler( + "POST", + "/api/rebuild-index", + headers={"X-Link-Local-Action": "true"}, + ) + + self.assertEqual(bad_post_status, 415) + self.assertFalse(bad_post_payload["rebuilt"]) + def test_validate_api_reports_wiki_gate_status(self): wiki = self.make_wiki() for dirname in ("sources", "concepts", "entities", "memories", "comparisons", "explorations"): From ed8f1fcef8f02b22562c13bf54f2ff2dd63264b2 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:50:50 -0600 Subject: [PATCH 138/292] Report smart query packet size --- CHANGELOG.md | 1 + README.md | 6 +++--- mcp_package/README.md | 2 +- mcp_package/link_core/query.py | 20 +++++++++++++++++++- tests/test_large_wiki_smoke.py | 1 + tests/test_query_core.py | 3 +++ 6 files changed, 28 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f423db6..68c5104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added agent memory briefs with `link.py brief` and MCP `memory_brief` so agents can prime themselves with relevant local memory before a task. - Added smart Link query packets with `link.py query`, MCP `query_link`, and `/api/query-link` so agents can retrieve budgeted memory, ranked wiki results, and graph context without reading the whole wiki. - Added smart query budget reports and follow-up tool actions so agents know when context was truncated and how to continue without scanning the whole wiki. +- Added estimated character/token counts to smart query budget reports so agents can reason about context cost. - Added `link.py validate` as an ingest gate for agent-generated wiki pages, covering required frontmatter, type/directory alignment, required sections, dead links, and stale backlinks. - Added MCP `validate_wiki` and `/api/validate` so agents can run the same ingest gate without shell access. - Added a runtime duplication guard in CI to block new large copied helper bodies across CLI, web, and MCP runtimes. diff --git a/README.md b/README.md index b466f68..36be458 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ Most agents should start with: | `link_status` | You are connecting to Link or troubleshooting setup and need version, readiness, counts, validation summary, and safe next actions. | | `migrate_wiki` | `link_status` reports a missing or old schema marker and you need a safe, idempotent local migration. | | `ingest_status` | The user dropped files into `raw/` or asks to ingest, and you need pending raw files plus the next prompt/checks and guided ingest plan. | -| `query_link` | You need one compact, answer-ready packet that combines relevant memory, ranked wiki results, graph context, budget limits, and follow-up actions without reading the whole wiki. | +| `query_link` | You need one compact, answer-ready packet that combines relevant memory, ranked wiki results, graph context, budget limits, estimated packet size, and follow-up actions without reading the whole wiki. | | `validate_wiki` | You just ingested sources or substantially edited pages and need to verify page shape, links, and backlink freshness before reporting done. | | `backup_wiki` | You are about to run broad repair work or risky local wiki edits and want a local `.link-backups/` archive first. | | `memory_brief` | You are starting a session or task and need Link to prime the agent with relevant memory, review warnings, and saved capture status. | @@ -523,7 +523,7 @@ Common endpoints: | `GET /api/memory-inbox?project=` | Memories that need review or metadata cleanup. | | `GET /api/capture-inbox?project=` | Saved raw captures with redacted snippets, secret-warning labels, and review commands. | | `GET /api/explain-memory?memory=` | Provenance, lifecycle, graph links, review state, and recall readiness. | -| `GET /api/query-link?q=&budget=small\|medium\|large` | Compact context packet with relevant memory, ranked wiki results, graph context, budget reports, follow-up actions, and selection reasons. | +| `GET /api/query-link?q=&budget=small\|medium\|large` | Compact context packet with relevant memory, ranked wiki results, graph context, budget reports with estimated size, follow-up actions, and selection reasons. | | `GET /api/validate?strict=true` | Validate generated wiki pages; failed gates return HTTP 422 with structured findings. | | `GET /api/proposal-sources` | Local raw text sources that can be loaded into `/propose`; snippets are redacted when secret-looking values are present. | | `GET /api/proposal-source?path=raw/file.md` | Load one safe raw text source into the proposal workflow; secret-warning files are refused until redacted. | @@ -556,7 +556,7 @@ repo-local or source checkout, use `python3 link.py ` in that directory | `link accept-capture [--index N]` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | | `link redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | | `link delete-capture --confirm` | Delete a saved raw capture after explicit confirmation. | -| `link query "task" [--budget small\|medium\|large] [--project slug]` | Build a compact answer-ready packet from memory, wiki search, and graph context. | +| `link query "task" [--budget small\|medium\|large] [--project slug]` | Build a compact answer-ready packet from memory, wiki search, graph context, and estimated packet size. | | `link brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, saved capture status, and safe memory rules. | | `link memory-audit [--project slug]` | Read-only health report for memory review backlog, raw captures, risk factors, and next actions. | | `link recall "query" [--project slug]` | Search local agent memories with recall readiness. | diff --git a/mcp_package/README.md b/mcp_package/README.md index 63ee6b4..fe11c6d 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -96,7 +96,7 @@ memory. Use `propose_memories` or `capture_session` for proposal-only review. | `link_status(include_validation?)` | Readiness summary with package version, wiki path, page/memory counts, optional validation summary, and safe next actions. | | `migrate_wiki()` | Apply safe, idempotent wiki schema migrations when `link_status` reports a missing or old schema marker. | | `ingest_status()` | Raw source ingest state with pending files, graph health, the next agent prompt, guided plan, and follow-up checks. | -| `query_link(query, budget?, project?)` | Build a compact answer-ready packet from local memory, ranked wiki search, graph-neighborhood context, budget reports, and follow-up actions. | +| `query_link(query, budget?, project?)` | Build a compact answer-ready packet from local memory, ranked wiki search, graph-neighborhood context, budget reports with estimated packet size, and follow-up actions. | | `validate_wiki(strict?)` | Validate agent-generated wiki pages after ingest or large edits: frontmatter, type/directory alignment, required sections, dead links, and backlink freshness. | | `backup_wiki(label?, include_raw?, list_only?)` | Create or list local `.link-backups/` archives before broad repairs or risky wiki edits; raw sources are excluded by default. | | `memory_brief(query?, limit?, project?)` | Prime the agent before answering or coding with profile counts, relevant memories, review warnings, and safe memory rules. | diff --git a/mcp_package/link_core/query.py b/mcp_package/link_core/query.py index 2fa4865..dd8d205 100644 --- a/mcp_package/link_core/query.py +++ b/mcp_package/link_core/query.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import json from pathlib import Path from typing import Any, Iterable, Mapping @@ -172,6 +173,23 @@ def _budget_item(selected: int, limit: int, has_more: bool) -> dict[str, object] } +def _estimated_json_chars(value: object) -> int: + return len(json.dumps(value, ensure_ascii=False, sort_keys=True)) + + +def _estimated_tokens(chars: int) -> int: + # Practical rough count for agent budgeting; exact tokenizers vary by model. + return max(1, (chars + 3) // 4) if chars else 0 + + +def _context_packet_budget_item(packet: list[dict[str, object]], limit: int) -> dict[str, object]: + chars = _estimated_json_chars(packet) + item = _budget_item(len(packet), limit, False) + item["estimated_chars"] = chars + item["estimated_tokens"] = _estimated_tokens(chars) + return item + + def _follow_up_actions( query: str, budget_name: str, @@ -285,7 +303,7 @@ def query_link( "memories": _budget_item(len(memories), limits["memories"], memory_has_more), "wiki_search": _budget_item(len(search_results), limits["search_results"], search_has_more), "graph_context": _budget_item(len(pages), limits["context_pages"], context_has_more), - "context_packet": _budget_item(len(packet), limits["memories"] + limits["context_pages"], False), + "context_packet": _context_packet_budget_item(packet, limits["memories"] + limits["context_pages"]), } if any(bool(section.get("has_more")) for section in budget_report.values()): guidance.insert(1, "This packet is budget-limited; use follow_up instead of scanning files manually.") diff --git a/tests/test_large_wiki_smoke.py b/tests/test_large_wiki_smoke.py index 54728b5..879deaa 100644 --- a/tests/test_large_wiki_smoke.py +++ b/tests/test_large_wiki_smoke.py @@ -110,6 +110,7 @@ def test_smart_query_and_graph_handle_hundreds_of_pages(self): self.assertTrue(packet["found"]) self.assertLessEqual(len(packet["context_packet"]), 6) self.assertTrue(packet["budget_report"]["wiki_search"]["has_more"]) + self.assertLess(packet["budget_report"]["context_packet"]["estimated_tokens"], 3000) self.assertLessEqual(packet["memory"]["count"], 3) self.assertEqual(packet["follow_up"][0]["tool"], "query_link") self.assertEqual(len(graph["nodes"]), page_count + 30) diff --git a/tests/test_query_core.py b/tests/test_query_core.py index ea9098d..31eb492 100644 --- a/tests/test_query_core.py +++ b/tests/test_query_core.py @@ -88,6 +88,8 @@ def test_query_link_returns_budgeted_memory_and_graph_context(self): self.assertIn("why_selected", payload["context_packet"][0]) self.assertIn("do not read the whole wiki", payload["agent_guidance"][0]) self.assertIn("budget_report", payload) + self.assertGreater(payload["budget_report"]["context_packet"]["estimated_chars"], 0) + self.assertGreater(payload["budget_report"]["context_packet"]["estimated_tokens"], 0) self.assertEqual(payload["follow_up"][0]["tool"], "get_context") def test_query_link_reports_budget_overflow_and_followups(self): @@ -142,6 +144,7 @@ def test_query_link_reports_budget_overflow_and_followups(self): self.assertTrue(payload["budget_report"]["memories"]["has_more"]) self.assertTrue(payload["budget_report"]["wiki_search"]["has_more"]) self.assertLessEqual(payload["budget_report"]["memories"]["selected"], 3) + self.assertLess(payload["budget_report"]["context_packet"]["estimated_tokens"], 2000) self.assertEqual(payload["follow_up"][0]["tool"], "query_link") self.assertEqual(payload["follow_up"][0]["arguments"]["budget"], "medium") self.assertIn("budget-limited", payload["agent_guidance"][1]) From 3a160dde8d24e1ad4ca93b0dc4dfa277b8315d65 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:53:05 -0600 Subject: [PATCH 139/292] Return bad request for missing context topic --- CHANGELOG.md | 1 + serve.py | 2 +- tests/test_serve.py | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c5104..c0bee91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -183,6 +183,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Fixed wiki cache invalidation so edits to existing pages refresh search and context. - Fixed MCP package reinstall behavior so rerunning installers upgrades `link-mcp`. - Fixed invalid HTTP search limits to return controlled JSON errors. +- Fixed missing HTTP context topics to return a controlled 400 JSON error. ## Earlier diff --git a/serve.py b/serve.py index 3911e4f..e8e3759 100644 --- a/serve.py +++ b/serve.py @@ -3473,7 +3473,7 @@ def do_GET(self): elif path == "/api/context": topic = query.get("topic", [""])[0].strip() or query.get("q", [""])[0].strip() if not topic: - self._json({"error": "topic parameter required"}) + self._json({"error": "topic parameter required"}, status=400) else: self._json(_get_context(topic)) else: diff --git a/tests/test_serve.py b/tests/test_serve.py index 6a91b37..3cf5b92 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -216,6 +216,14 @@ def test_context_deduplicates_forward_links(self): self.assertEqual(ctx["forward_count"], 2) self.assertEqual([page["name"] for page in ctx["pages"]], ["a", "b", "c"]) + def test_context_api_requires_topic_with_bad_request(self): + self.make_wiki() + + status, payload = run_handler("GET", "/api/context") + + self.assertEqual(status, 400) + self.assertEqual(payload["error"], "topic parameter required") + def test_inline_markdown_sanitizes_html_and_links(self): rendered = serve._md_to_html( "Hello " From 932622a21ec6157016f8fd1338a6f47b33f4a7f0 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:54:53 -0600 Subject: [PATCH 140/292] Move context API note to unreleased --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0bee91..04467fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Fixed installer MCP setup reporting so failed upgrades no longer masquerade as success by reusing an unrelated older global `link-mcp`. - Fixed project-mode installer output so MCP wiki paths are absolute and next-step hints point at the project wiki instead of `~/link`. - Fixed search/context matching for natural queries against hyphenated page slugs, e.g. `local first software` now finds `local-first-software`. +- Fixed missing HTTP context topics to return a controlled 400 JSON error. - Hardened backlink rebuild over HTTP so local web rebuilds require JSON POST instead of a mutating GET. - Hardened HTTP rebuild actions so local web index/backlink mutations require the explicit local-action header. - Hardened `/raw/` static serving so the local web viewer only serves supported media/PDF source assets. @@ -183,7 +184,6 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Fixed wiki cache invalidation so edits to existing pages refresh search and context. - Fixed MCP package reinstall behavior so rerunning installers upgrades `link-mcp`. - Fixed invalid HTTP search limits to return controlled JSON errors. -- Fixed missing HTTP context topics to return a controlled 400 JSON error. ## Earlier From 90246587b567488c1830ea91836b169e5267d005 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 20:57:41 -0600 Subject: [PATCH 141/292] Avoid repeated large query followups --- CHANGELOG.md | 1 + mcp_package/link_core/query.py | 18 +++++++++------- tests/test_query_core.py | 38 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04467fb..87ddd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added smart Link query packets with `link.py query`, MCP `query_link`, and `/api/query-link` so agents can retrieve budgeted memory, ranked wiki results, and graph context without reading the whole wiki. - Added smart query budget reports and follow-up tool actions so agents know when context was truncated and how to continue without scanning the whole wiki. - Added estimated character/token counts to smart query budget reports so agents can reason about context cost. +- Improved smart query follow-ups so a truncated large-budget packet does not ask the agent to rerun the same large budget again. - Added `link.py validate` as an ingest gate for agent-generated wiki pages, covering required frontmatter, type/directory alignment, required sections, dead links, and stale backlinks. - Added MCP `validate_wiki` and `/api/validate` so agents can run the same ingest gate without shell access. - Added a runtime duplication guard in CI to block new large copied helper bodies across CLI, web, and MCP runtimes. diff --git a/mcp_package/link_core/query.py b/mcp_package/link_core/query.py index dd8d205..b2c5c5e 100644 --- a/mcp_package/link_core/query.py +++ b/mcp_package/link_core/query.py @@ -199,14 +199,16 @@ def _follow_up_actions( ) -> list[dict[str, object]]: actions: list[dict[str, object]] = [] if any(bool(section.get("has_more")) for section in budget_report.values()): - args: dict[str, object] = {"query": query, "budget": _next_budget(budget_name)} - if project: - args["project"] = project - actions.append({ - "when": "packet is relevant but too thin", - "tool": "query_link", - "arguments": args, - }) + next_budget = _next_budget(budget_name) + if next_budget != budget_name: + args: dict[str, object] = {"query": query, "budget": next_budget} + if project: + args["project"] = project + actions.append({ + "when": "packet is relevant but too thin", + "tool": "query_link", + "arguments": args, + }) if primary: actions.append({ "when": "need the full source-backed topic neighborhood", diff --git a/tests/test_query_core.py b/tests/test_query_core.py index 31eb492..e4ab204 100644 --- a/tests/test_query_core.py +++ b/tests/test_query_core.py @@ -149,6 +149,44 @@ def test_query_link_reports_budget_overflow_and_followups(self): self.assertEqual(payload["follow_up"][0]["arguments"]["budget"], "medium") self.assertIn("budget-limited", payload["agent_guidance"][1]) + def test_large_budget_followup_does_not_repeat_large_budget(self): + root = Path(tempfile.mkdtemp(prefix="link-query-core-")) + wiki = root / "wiki" + wiki.mkdir() + write_page(wiki, "index.md", "# Index\n") + write_page(wiki, "log.md", "# Log\n") + for index in range(12): + write_page( + wiki, + f"concepts/agent-memory-{index}.md", + "---\n" + "type: concept\n" + f"title: Agent memory {index}\n" + "tags: [agents, memory]\n" + "---\n\n" + f"# Agent memory {index}\n\n" + "> **TLDR:** Agents use durable memory.\n\n" + "## Overview\n\nAgent memory supports source-backed local recall.\n", + ) + (wiki / "_backlinks.json").write_text(json.dumps(build_backlinks(wiki)), encoding="utf-8") + + payload = query_link( + wiki, + "agent memory", + build_wiki_cache(wiki), + memory_records(wiki), + budget="large", + ) + + self.assertTrue(payload["budget_report"]["wiki_search"]["has_more"]) + self.assertFalse( + any( + action["tool"] == "query_link" and action.get("arguments", {}).get("budget") == "large" + for action in payload["follow_up"] + ) + ) + self.assertEqual(payload["follow_up"][0]["tool"], "get_context") + if __name__ == "__main__": unittest.main() From e78d1c0394323918035525de73ae17111c0e3310 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 21:36:50 -0600 Subject: [PATCH 142/292] Add provenance to smart query packets --- CHANGELOG.md | 1 + LINK.md | 2 +- README.md | 6 +++--- mcp_package/README.md | 4 ++-- mcp_package/link_core/query.py | 35 +++++++++++++++++++++++++++++++++- mcp_package/link_core/wiki.py | 8 ++++++++ tests/test_query_core.py | 12 ++++++++++++ 7 files changed, 61 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ddd0e..630fcda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added smart Link query packets with `link.py query`, MCP `query_link`, and `/api/query-link` so agents can retrieve budgeted memory, ranked wiki results, and graph context without reading the whole wiki. - Added smart query budget reports and follow-up tool actions so agents know when context was truncated and how to continue without scanning the whole wiki. - Added estimated character/token counts to smart query budget reports so agents can reason about context cost. +- Added provenance metadata to smart query memory and wiki packets so agents can explain why Link knows something without loading full pages. - Improved smart query follow-ups so a truncated large-budget packet does not ask the agent to rerun the same large budget again. - Added `link.py validate` as an ingest gate for agent-generated wiki pages, covering required frontmatter, type/directory alignment, required sections, dead links, and stale backlinks. - Added MCP `validate_wiki` and `/api/validate` so agents can run the same ingest gate without shell access. diff --git a/LINK.md b/LINK.md index 6f5ac90..0570b1c 100644 --- a/LINK.md +++ b/LINK.md @@ -352,7 +352,7 @@ When the human asks a question: 1. If you are connecting to Link for the first time or troubleshooting setup, call MCP `link_status`, run `python3 link.py status . --validate`, or call `GET /api/status?validate=true`. 2. If status reports a missing or old schema marker, run MCP `migrate_wiki` when available or `python3 link.py migrate .` before other writes. 3. If the user asks to ingest or says they dropped files into `raw/`, use MCP `ingest_status`, `python3 link.py ingest-status .`, or `GET /api/ingest-status` to get pending files, the guided ingest plan, and the next prompt/checks. -4. Start with the smart query path when available: MCP `query_link`, `python3 link.py query "" .`, or `GET /api/query-link?q=`. This returns a compact context packet with relevant memory, ranked wiki results, graph context, selection reasons, budget reports, and follow-up tool actions. Do not read the whole wiki unless the packet is insufficient; if it is budget-limited, use the returned `follow_up` action first. +4. Start with the smart query path when available: MCP `query_link`, `python3 link.py query "" .`, or `GET /api/query-link?q=`. This returns a compact context packet with relevant memory, ranked wiki results, graph context, provenance, selection reasons, budget reports, and follow-up tool actions. Use provenance fields to explain why Link knows something. Do not read the whole wiki unless the packet is insufficient; if it is budget-limited, use the returned `follow_up` action first. 5. If the question only needs session priming or personal/project preferences, use `python3 link.py brief "" .` or MCP `memory_brief`. Use `profile`/`memory_profile` and `recall`/`recall_memory` afterward only when you need deeper detail. 6. **If you need full source-backed context for one topic:** call `GET /api/context?topic=` or MCP `get_context` — returns the best matching page plus related pages via graph traversal. 7. **If server/MCP is not available:** read `wiki/index.md` to find relevant pages (check `also:` aliases for matches), then check `wiki/_backlinks.json` for pages that reference the topic. diff --git a/README.md b/README.md index 36be458..621b6dc 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ Most agents should start with: | `link_status` | You are connecting to Link or troubleshooting setup and need version, readiness, counts, validation summary, and safe next actions. | | `migrate_wiki` | `link_status` reports a missing or old schema marker and you need a safe, idempotent local migration. | | `ingest_status` | The user dropped files into `raw/` or asks to ingest, and you need pending raw files plus the next prompt/checks and guided ingest plan. | -| `query_link` | You need one compact, answer-ready packet that combines relevant memory, ranked wiki results, graph context, budget limits, estimated packet size, and follow-up actions without reading the whole wiki. | +| `query_link` | You need one compact, answer-ready packet that combines relevant memory, ranked wiki results, graph context, provenance, budget limits, estimated packet size, and follow-up actions without reading the whole wiki. | | `validate_wiki` | You just ingested sources or substantially edited pages and need to verify page shape, links, and backlink freshness before reporting done. | | `backup_wiki` | You are about to run broad repair work or risky local wiki edits and want a local `.link-backups/` archive first. | | `memory_brief` | You are starting a session or task and need Link to prime the agent with relevant memory, review warnings, and saved capture status. | @@ -523,7 +523,7 @@ Common endpoints: | `GET /api/memory-inbox?project=` | Memories that need review or metadata cleanup. | | `GET /api/capture-inbox?project=` | Saved raw captures with redacted snippets, secret-warning labels, and review commands. | | `GET /api/explain-memory?memory=` | Provenance, lifecycle, graph links, review state, and recall readiness. | -| `GET /api/query-link?q=&budget=small\|medium\|large` | Compact context packet with relevant memory, ranked wiki results, graph context, budget reports with estimated size, follow-up actions, and selection reasons. | +| `GET /api/query-link?q=&budget=small\|medium\|large` | Compact context packet with relevant memory, ranked wiki results, graph context, provenance, budget reports with estimated size, follow-up actions, and selection reasons. | | `GET /api/validate?strict=true` | Validate generated wiki pages; failed gates return HTTP 422 with structured findings. | | `GET /api/proposal-sources` | Local raw text sources that can be loaded into `/propose`; snippets are redacted when secret-looking values are present. | | `GET /api/proposal-source?path=raw/file.md` | Load one safe raw text source into the proposal workflow; secret-warning files are refused until redacted. | @@ -556,7 +556,7 @@ repo-local or source checkout, use `python3 link.py ` in that directory | `link accept-capture [--index N]` | Accept one proposal from a saved raw capture using duplicate/conflict-safe memory writes. | | `link redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | | `link delete-capture --confirm` | Delete a saved raw capture after explicit confirmation. | -| `link query "task" [--budget small\|medium\|large] [--project slug]` | Build a compact answer-ready packet from memory, wiki search, graph context, and estimated packet size. | +| `link query "task" [--budget small\|medium\|large] [--project slug]` | Build a compact answer-ready packet from memory, wiki search, graph context, provenance, and estimated packet size. | | `link brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, saved capture status, and safe memory rules. | | `link memory-audit [--project slug]` | Read-only health report for memory review backlog, raw captures, risk factors, and next actions. | | `link recall "query" [--project slug]` | Search local agent memories with recall readiness. | diff --git a/mcp_package/README.md b/mcp_package/README.md index fe11c6d..8386f31 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -96,7 +96,7 @@ memory. Use `propose_memories` or `capture_session` for proposal-only review. | `link_status(include_validation?)` | Readiness summary with package version, wiki path, page/memory counts, optional validation summary, and safe next actions. | | `migrate_wiki()` | Apply safe, idempotent wiki schema migrations when `link_status` reports a missing or old schema marker. | | `ingest_status()` | Raw source ingest state with pending files, graph health, the next agent prompt, guided plan, and follow-up checks. | -| `query_link(query, budget?, project?)` | Build a compact answer-ready packet from local memory, ranked wiki search, graph-neighborhood context, budget reports with estimated packet size, and follow-up actions. | +| `query_link(query, budget?, project?)` | Build a compact answer-ready packet from local memory, ranked wiki search, graph-neighborhood context, provenance, budget reports with estimated packet size, and follow-up actions. | | `validate_wiki(strict?)` | Validate agent-generated wiki pages after ingest or large edits: frontmatter, type/directory alignment, required sections, dead links, and backlink freshness. | | `backup_wiki(label?, include_raw?, list_only?)` | Create or list local `.link-backups/` archives before broad repairs or risky wiki edits; raw sources are excluded by default. | | `memory_brief(query?, limit?, project?)` | Prime the agent before answering or coding with profile counts, relevant memories, review warnings, and safe memory rules. | @@ -125,7 +125,7 @@ memory. Use `propose_memories` or `capture_session` for proposal-only review. | `rebuild_index()` | Regenerate `wiki/index.md` from current pages so the human-readable catalog stays complete. | | `rebuild_backlinks()` | Rebuild `_backlinks.json` after ingest or lint. | -Use `link_status` when connecting to Link or troubleshooting setup; if it reports a missing or old schema marker, call `migrate_wiki` before other writes. Use `ingest_status` when the user drops files into `raw/` or asks what still needs ingest. Start with `query_link` for substantive questions that may need both local memory and wiki context. If `budget_report` says context was truncated, use the returned `follow_up` action before scanning files manually. Use `memory_brief`, passing the user's task as `query` when available, at session start or before personalized/project work. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. After ingesting sources or substantially editing wiki pages, call `rebuild_index`, `rebuild_backlinks`, then `validate_wiki`, before saying the wiki is updated. Use `backup_wiki` before broad repairs or risky local wiki edits; raw sources are excluded unless the user explicitly asks to include them. Use `memory_profile` to inspect the user/project memory shape, `memory_audit` to see review/capture risks, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. Use `capture_inbox` to review saved captures before accepting, redacting, or deleting them. If `capture_session` reports secret warnings, ask before calling `redact_capture`. Use `accept_capture` only after the user approves one captured proposal. Use `delete_capture` only after explicit user confirmation. If `remember_memory` or `accept_capture` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `forget_memory` only when the user explicitly asks for permanent deletion. Use `get_context` when you need the full primary source page after `query_link` shows it is relevant. +Use `link_status` when connecting to Link or troubleshooting setup; if it reports a missing or old schema marker, call `migrate_wiki` before other writes. Use `ingest_status` when the user drops files into `raw/` or asks what still needs ingest. Start with `query_link` for substantive questions that may need both local memory and wiki context. Use each item provenance to explain why Link knows something; if `budget_report` says context was truncated, use the returned `follow_up` action before scanning files manually. Use `memory_brief`, passing the user's task as `query` when available, at session start or before personalized/project work. Pass `project` for repo-specific work so Link returns broad user/global memory plus that project's memory, while keeping other explicit projects out of recall and duplicate/conflict checks. After ingesting sources or substantially editing wiki pages, call `rebuild_index`, `rebuild_backlinks`, then `validate_wiki`, before saying the wiki is updated. Use `backup_wiki` before broad repairs or risky local wiki edits; raw sources are excluded unless the user explicitly asks to include them. Use `memory_profile` to inspect the user/project memory shape, `memory_audit` to see review/capture risks, `memory_inbox` to find memories needing human review and the primary action for each item, `explain_memory` to audit why a memory exists, then `recall_memory` for focused preferences, decisions, and project context. Use `capture_session` for long chat/session notes that should be preserved locally before approval; use `propose_memories` when no raw capture is needed. Both return candidates only. Use `capture_inbox` to review saved captures before accepting, redacting, or deleting them. If `capture_session` reports secret warnings, ask before calling `redact_capture`. Use `accept_capture` only after the user approves one captured proposal. Use `delete_capture` only after explicit user confirmation. If `remember_memory` or `accept_capture` returns duplicate candidates, use `update_memory` on the existing memory unless the user confirms a separate memory. If it returns conflict candidates, ask the user whether to update or archive the older memory before forcing a conflict. Use `archive_memory`, not deletion, when a memory is stale or wrong. Use `forget_memory` only when the user explicitly asks for permanent deletion. Use `get_context` when you need the full primary source page after `query_link` shows it is relevant. ## Wiki location diff --git a/mcp_package/link_core/query.py b/mcp_package/link_core/query.py index b2c5c5e..643d0e5 100644 --- a/mcp_package/link_core/query.py +++ b/mcp_package/link_core/query.py @@ -71,6 +71,23 @@ def _memory_reason(memory: Mapping[str, object]) -> str: return "; ".join(parts) +def _drop_empty(data: dict[str, object]) -> dict[str, object]: + return {key: value for key, value in data.items() if value not in ("", [], {})} + + +def _memory_provenance(memory: Mapping[str, object]) -> dict[str, object]: + return _drop_empty({ + "path": memory.get("path", ""), + "source": memory.get("source", ""), + "date_captured": memory.get("date_captured", ""), + "updated_at": memory.get("updated_at", ""), + "last_update_source": memory.get("last_update_source", ""), + "review_status": memory.get("review_status", ""), + "reviewed_at": memory.get("reviewed_at", ""), + "status": memory.get("status", ""), + }) + + def _page_reason(page: Mapping[str, object]) -> str: relationship = str(page.get("relationship") or "") if relationship == "primary": @@ -82,6 +99,18 @@ def _page_reason(page: Mapping[str, object]) -> str: return "related wiki page" +def _page_provenance(page: Mapping[str, object]) -> dict[str, object]: + return _drop_empty({ + "path": page.get("path", ""), + "relationship": page.get("relationship", ""), + "type": page.get("type", ""), + "category": page.get("category", ""), + "source_count": page.get("source_count", ""), + "date_updated": page.get("date_updated", ""), + "date_published": page.get("date_published", ""), + }) + + def _compact_memory(memory: Mapping[str, object]) -> dict[str, object]: item = { "kind": "memory", @@ -98,9 +127,10 @@ def _compact_memory(memory: Mapping[str, object]) -> dict[str, object]: "recall": memory.get("recall", {}), "review_issue_count": memory.get("review_issue_count", 0), "highest_review_severity": memory.get("highest_review_severity", "none"), + "provenance": _memory_provenance(memory), "why_selected": _memory_reason(memory), } - return {key: value for key, value in item.items() if value not in ("", [], {})} + return _drop_empty(item) def _compact_page(page: Mapping[str, object], primary_chars: int, neighbor_chars: int) -> dict[str, object]: @@ -114,6 +144,7 @@ def _compact_page(page: Mapping[str, object], primary_chars: int, neighbor_chars "relationship": relationship, "is_primary": bool(page.get("is_primary")), "content": _trim_text(page.get("content", ""), max_chars), + "provenance": _page_provenance(page), "why_selected": _page_reason(page), } @@ -126,6 +157,7 @@ def _compact_search_result(page: Mapping[str, object]) -> dict[str, object]: "category": page.get("category", ""), "score": page.get("score", 0), "snippet": page.get("snippet", ""), + "provenance": _page_provenance(page), } @@ -295,6 +327,7 @@ def query_link( guidance = [ "Use this packet before answering; do not read the whole wiki unless this packet is insufficient.", "Prefer recall-ready reviewed memories for personalization and source-backed wiki pages for factual claims.", + "Use provenance.path/source/date fields to explain why Link knows something.", "If important context appears missing, rerun query_link with a larger budget or call get_context on the primary page.", "Do not create or update memory from this packet unless the user explicitly asks.", ] diff --git a/mcp_package/link_core/wiki.py b/mcp_package/link_core/wiki.py index 5ee9429..176ca0a 100644 --- a/mcp_package/link_core/wiki.py +++ b/mcp_package/link_core/wiki.py @@ -106,6 +106,7 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: page = { "name": md.stem, + "path": f"wiki/{rel.as_posix()}", "title": title, "category": category, "type": meta.get("type", ""), @@ -341,6 +342,7 @@ def context_for_topic( page_path = cache["page_index"].get(name) if not page_path or not page_path.exists(): continue + cached_page = cache.get("page_map", {}).get(name, {}) text = page_path.read_text(encoding="utf-8", errors="replace") meta, body = parse_frontmatter(text) is_primary = name == primary_name @@ -355,8 +357,14 @@ def context_for_topic( content = "\n".join(summary_lines) context_pages.append({ "name": name, + "path": cached_page.get("path") or f"wiki/{page_path.relative_to(wiki_dir).as_posix()}", "title": meta.get("title", name), + "category": cached_page.get("category", ""), "type": meta.get("type", ""), + "source_count": cached_page.get("source_count", ""), + "tldr": cached_page.get("tldr", ""), + "date_updated": cached_page.get("date_updated", ""), + "date_published": cached_page.get("date_published", ""), "is_primary": is_primary, "relationship": "primary" if is_primary else ("inbound" if name in inbound else "forward"), "content": content, diff --git a/tests/test_query_core.py b/tests/test_query_core.py index e4ab204..47bedd1 100644 --- a/tests/test_query_core.py +++ b/tests/test_query_core.py @@ -33,6 +33,8 @@ def test_query_link_returns_budgeted_memory_and_graph_context(self): "type: concept\n" "title: Agent memory\n" "tags: [agents, memory]\n" + "source_count: 2\n" + "date_updated: \"2026-05-06\"\n" "---\n\n" "# Agent memory\n\n" "> **TLDR:** Agents use durable memory to preserve context.\n\n" @@ -82,11 +84,21 @@ def test_query_link_returns_budgeted_memory_and_graph_context(self): self.assertEqual(payload["strategy"]["mode"], "memory+wiki") self.assertEqual(payload["memory"]["items"][0]["name"], "prefer-local-memory") self.assertEqual(payload["memory"]["items"][0]["recall"]["state"], "ready") + self.assertEqual(payload["memory"]["items"][0]["provenance"]["path"], "wiki/memories/prefer-local-memory.md") + self.assertEqual(payload["memory"]["items"][0]["provenance"]["source"], "unit-test") + self.assertEqual(payload["memory"]["items"][0]["provenance"]["date_captured"], "2026-05-05T00:00:00Z") + self.assertEqual(payload["memory"]["items"][0]["provenance"]["review_status"], "reviewed") self.assertEqual(payload["memory"]["review"]["items"], []) self.assertEqual(payload["wiki"]["primary"], "agent-memory") + self.assertEqual(payload["wiki"]["pages"][0]["provenance"]["path"], "wiki/concepts/agent-memory.md") + self.assertEqual(payload["wiki"]["pages"][0]["provenance"]["source_count"], "2") + self.assertEqual(payload["wiki"]["pages"][0]["provenance"]["date_updated"], "2026-05-06") + self.assertEqual(payload["wiki"]["search_results"][0]["provenance"]["path"], "wiki/concepts/agent-memory.md") self.assertLessEqual(len(payload["context_packet"]), 4) self.assertIn("why_selected", payload["context_packet"][0]) + self.assertIn("provenance", payload["context_packet"][0]) self.assertIn("do not read the whole wiki", payload["agent_guidance"][0]) + self.assertIn("provenance.path/source/date", payload["agent_guidance"][2]) self.assertIn("budget_report", payload) self.assertGreater(payload["budget_report"]["context_packet"]["estimated_chars"], 0) self.assertGreater(payload["budget_report"]["context_packet"]["estimated_tokens"], 0) From dabdb2b91158716d5d12134ac1b9995c8739fe9c Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 21:39:12 -0600 Subject: [PATCH 143/292] Precompute wiki search word indexes --- CHANGELOG.md | 1 + mcp_package/link_core/wiki.py | 34 +++++++++++++++++++++++++++------- tests/test_wiki_core.py | 2 ++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 630fcda..b9f3010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added smart query budget reports and follow-up tool actions so agents know when context was truncated and how to continue without scanning the whole wiki. - Added estimated character/token counts to smart query budget reports so agents can reason about context cost. - Added provenance metadata to smart query memory and wiki packets so agents can explain why Link knows something without loading full pages. +- Added precomputed search word indexes so repeated wiki search and smart query calls avoid rebuilding per-page word sets on larger wikis. - Improved smart query follow-ups so a truncated large-budget packet does not ask the agent to rerun the same large budget again. - Added `link.py validate` as an ingest gate for agent-generated wiki pages, covering required frontmatter, type/directory alignment, required sections, dead links, and stale backlinks. - Added MCP `validate_wiki` and `/api/validate` so agents can run the same ingest gate without shell access. diff --git a/mcp_package/link_core/wiki.py b/mcp_package/link_core/wiki.py index 176ca0a..033fa5d 100644 --- a/mcp_package/link_core/wiki.py +++ b/mcp_package/link_core/wiki.py @@ -38,6 +38,10 @@ def normalized_search_text(value: object) -> str: return re.sub(r"\s+", " ", text).strip() +def _search_words(value: object) -> set[str]: + return {word for word in re.split(r"\W+", normalized_search_text(value)) if len(word) >= 3} + + def wiki_mtime(wiki_dir: Path) -> float: """Return an mtime signal for files that affect wiki indexes.""" try: @@ -85,6 +89,8 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: page_index: dict[str, Path] = {} fulltext: dict[str, str] = {} normalized_fulltext: dict[str, str] = {} + text_words_index: dict[str, set[str]] = {} + meta_words_index: dict[str, set[str]] = {} snippet_index: dict[str, str] = {} token_index: dict[str, set[str]] = {} meta_token_index: dict[str, set[str]] = {} @@ -126,7 +132,9 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: text_lower = text.lower() fulltext[stem] = text_lower - normalized_fulltext[stem] = normalized_search_text(text_lower) + text_normalized = normalized_search_text(text_lower) + normalized_fulltext[stem] = text_normalized + text_words_index[stem] = _search_words(text_normalized) snippet_index[stem] = _body_snippet(body) for token in re.split(r"\W+", text_lower): @@ -151,6 +159,13 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: meta_tokens.add(word) for token in meta_tokens: meta_token_index.setdefault(token, set()).add(stem) + meta_words_index[stem] = _search_words(" ".join([ + title, + stem, + tldr, + " ".join(str(alias) for alias in aliases), + " ".join(str(tag) for tag in tags_raw), + ])) return { "mtime": wiki_mtime(wiki_dir), @@ -158,6 +173,8 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: "page_index": page_index, "fulltext": fulltext, "normalized_fulltext": normalized_fulltext, + "text_words_index": text_words_index, + "meta_words_index": meta_words_index, "snippet_index": snippet_index, "token_index": token_index, "meta_token_index": meta_token_index, @@ -224,6 +241,8 @@ def search_pages(query: str, cache: dict[str, Any], limit: int = 20) -> list[dic meta_token_index = cache["meta_token_index"] fulltext = cache["fulltext"] normalized_fulltext = cache.get("normalized_fulltext", {}) + text_words_index = cache.get("text_words_index", {}) + meta_words_index = cache.get("meta_words_index", {}) snippet_index = cache["snippet_index"] is_single_token = bool(re.match(r"^\w+$", q_lower)) @@ -255,16 +274,15 @@ def search_pages(query: str, cache: dict[str, Any], limit: int = 20) -> list[dic tags = page.get("tags", []) tldr = page.get("tldr", "") text_lower = fulltext.get(stem, "") - meta_words = set(re.split( - r"\W+", - normalized_search_text(" ".join([ + meta_words = meta_words_index.get(stem) + if meta_words is None: + meta_words = _search_words(" ".join([ str(page["title"]), stem, str(tldr), " ".join(str(alias) for alias in aliases), " ".join(str(tag) for tag in tags), - ])), - )) + ])) if q_lower in str(page["title"]).lower() or (q_normalized and q_normalized in title_normalized): score += 10 @@ -284,7 +302,9 @@ def search_pages(query: str, cache: dict[str, Any], limit: int = 20) -> list[dic elif query_tokens and any(token in meta_words for token in query_tokens): score += 1 if query_tokens and text_normalized: - text_words = set(re.split(r"\W+", text_normalized)) + text_words = text_words_index.get(stem) + if text_words is None: + text_words = _search_words(text_normalized) if all(token in text_words for token in query_tokens): score += 2 if score > 0: diff --git a/tests/test_wiki_core.py b/tests/test_wiki_core.py index 5182648..13cb6e3 100644 --- a/tests/test_wiki_core.py +++ b/tests/test_wiki_core.py @@ -79,6 +79,8 @@ def test_cache_search_context_and_graph(self): self.assertEqual(search[0]["name"], "agent-memory") self.assertIn("date_published", search[0]) + self.assertIn("durable", cache["meta_words_index"]["agent-memory"]) + self.assertIn("references", cache["text_words_index"]["link"]) self.assertEqual(context["primary"], "agent-memory") self.assertEqual(context["inbound_count"], 1) self.assertEqual(context["forward_count"], 2) From bfc3b50791a671f138f9fb07e36d37028d5f8c29 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 21:42:56 -0600 Subject: [PATCH 144/292] Add local Link benchmark command --- CHANGELOG.md | 1 + README.md | 1 + link.py | 106 +++++++++++++++++++++++++++++++++ scripts/check_tool_contract.py | 1 + tests/test_link_cli.py | 23 +++++++ 5 files changed, 132 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f3010..2d66cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added a managed `~/.local/bin/link` command for global installs so users can run `link status --validate`, `link query`, and `link brief` without remembering wiki paths. - Added `link init` to create or repair a normal Link wiki without loading demo content. - Added `link serve` to start the local web viewer without remembering `serve.py` paths. +- Added `link benchmark` to measure local cache, search, smart query, and graph timings on a user's current wiki. - Added wiki schema markers with safe `link migrate`/MCP `migrate_wiki` migrations for future local format changes. - Added first-run agent prompts to installer output so new users can immediately try brief, remember, and query workflows. - Added guided `link ingest-status` output with structured JSON guidance, exact agent prompts, and follow-up validation commands. diff --git a/README.md b/README.md index 621b6dc..63f5404 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,7 @@ repo-local or source checkout, use `python3 link.py ` in that directory | `link redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | | `link delete-capture --confirm` | Delete a saved raw capture after explicit confirmation. | | `link query "task" [--budget small\|medium\|large] [--project slug]` | Build a compact answer-ready packet from memory, wiki search, graph context, provenance, and estimated packet size. | +| `link benchmark ["query"] [--budget small\|medium\|large] [--project slug]` | Measure local cache, search, smart query, and graph timings for your current wiki. | | `link brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, saved capture status, and safe memory rules. | | `link memory-audit [--project slug]` | Read-only health report for memory review backlog, raw captures, risk factors, and next actions. | | `link recall "query" [--project slug]` | Search local agent memories with recall readiness. | diff --git a/link.py b/link.py index 9439a3f..7ccbab0 100644 --- a/link.py +++ b/link.py @@ -16,6 +16,7 @@ python link.py capture-inbox [target] python link.py update-memory "new memory text" [target] python link.py query "task or question" [target] + python link.py benchmark ["query"] [target] python link.py brief ["task or question"] [target] python link.py recall "query" [target] python link.py profile [target] @@ -40,6 +41,7 @@ import shutil import subprocess import sys +import time from pathlib import Path from typing import Callable @@ -171,7 +173,9 @@ from link_core.wiki import ( build_backlinks as _core_build_backlinks, build_wiki_cache as _core_build_wiki_cache, + graph_data as _core_graph_data, rebuild_index as _core_rebuild_index, + search_pages as _core_search_pages, ) del _BUNDLED_CORE @@ -2637,6 +2641,93 @@ def query( return 0 +def _timed(label: str, fn: Callable[[], object]) -> tuple[str, object, float]: + start = time.perf_counter() + value = fn() + return label, value, time.perf_counter() - start + + +def benchmark( + target: Path, + query_text: str = "agent memory", + budget: str = "small", + project: str | None = None, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + project_name = project or _default_project(target) + timings: dict[str, float] = {} + + label, cache, elapsed = _timed("cache", lambda: _core_build_wiki_cache(wiki_dir)) + timings[label] = elapsed + label, results, elapsed = _timed("search", lambda: _core_search_pages(query_text, cache, limit=20)) + timings[label] = elapsed + label, packet, elapsed = _timed( + "query", + lambda: _core_query_link( + wiki_dir, + query_text, + cache, + _memory_records(wiki_dir), + budget=budget, + project=project_name, + review_command="review-memory", + ), + ) + timings[label] = elapsed + label, graph, elapsed = _timed("graph", lambda: _core_graph_data(cache)) + timings[label] = elapsed + + budget_report = packet.get("budget_report", {}) if isinstance(packet, dict) else {} + payload = { + "target": str(target), + "wiki": str(wiki_dir), + "query": query_text, + "budget": budget, + "project": project_name, + "pages": len(cache.get("pages", [])), + "memories": len(_memory_records(wiki_dir)), + "edges": len(graph.get("edges", [])) if isinstance(graph, dict) else 0, + "search_results": len(results) if isinstance(results, list) else 0, + "context_items": len(packet.get("context_packet", [])) if isinstance(packet, dict) else 0, + "found": bool(packet.get("found")) if isinstance(packet, dict) else False, + "timings": {key: round(value, 4) for key, value in timings.items()}, + "budget_report": budget_report, + } + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + + print(f"Link benchmark: {target}") + print(f"Query: {query_text}") + if project_name: + print(f"Project: {project_name}") + print("") + print(f"Scale: {payload['pages']} pages · {payload['memories']} memories · {payload['edges']} edges") + print(f"Results: {payload['search_results']} search results · {payload['context_items']} context items") + print("") + print("Timings") + for key in ("cache", "search", "query", "graph"): + print(f"- {key}: {payload['timings'][key]:.4f}s") + if isinstance(budget_report, dict): + packet_report = budget_report.get("context_packet") + if isinstance(packet_report, dict): + print("") + print( + "Packet: " + f"{packet_report.get('estimated_chars', 0)} chars · " + f"{packet_report.get('estimated_tokens', 0)} tokens · " + f"has_more={packet_report.get('has_more', False)}" + ) + print("") + print(f"Result: {'found' if payload['found'] else 'no matching context'}") + return 0 + + def brief( target: Path, query: str = "", @@ -3201,6 +3292,13 @@ def main(argv: list[str] | None = None) -> int: query_cmd.add_argument("--project", default=None, help="include user/global memories plus this project's memories") query_cmd.add_argument("--json", action="store_true", help="print machine-readable context packet") + benchmark_cmd = sub.add_parser("benchmark", help="measure local search, query, and graph performance") + benchmark_cmd.add_argument("query", nargs="?", default="agent memory", help="query to benchmark") + benchmark_cmd.add_argument("target", nargs="?", default=".") + benchmark_cmd.add_argument("--budget", choices=("small", "medium", "large"), default="small") + benchmark_cmd.add_argument("--project", default=None, help="include user/global memories plus this project's memories") + benchmark_cmd.add_argument("--json", action="store_true", help="print machine-readable benchmark data") + brief_cmd = sub.add_parser("brief", help="prime an agent with relevant local memory") brief_cmd.add_argument("query", nargs="?", default="", help="optional task or question to retrieve memory for") brief_cmd.add_argument("target", nargs="?", default=".") @@ -3385,6 +3483,14 @@ def main(argv: list[str] | None = None) -> int: project=args.project, json_output=args.json, ) + if args.command == "benchmark": + return benchmark( + Path(args.target), + query_text=args.query, + budget=args.budget, + project=args.project, + json_output=args.json, + ) if args.command == "brief": return brief(Path(args.target), query=args.query, limit=args.limit, project=args.project, json_output=args.json) if args.command == "profile": diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 7b11d46..6dab0f0 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -12,6 +12,7 @@ "accept-capture", "archive-memory", "backup", + "benchmark", "brief", "capture-inbox", "capture-session", diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 11fa8c1..2f2344f 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -766,6 +766,29 @@ def test_query_builds_context_packet(self): self.assertEqual(payload["memory"]["items"][0]["name"], "prefer-local-personal-memory") self.assertIn("context_packet", payload) + def test_benchmark_reports_local_query_timings(self): + tmp = Path(tempfile.mkdtemp(prefix="link-benchmark-test-")) + target = tmp / "demo" + create_demo_quiet(target) + + out = StringIO() + with redirect_stdout(out): + code = link_cli.benchmark(target, "agent memory", budget="small", json_output=True) + + payload = json.loads(out.getvalue()) + self.assertEqual(code, 0) + self.assertEqual(payload["query"], "agent memory") + self.assertTrue(payload["found"]) + self.assertGreaterEqual(payload["pages"], 1) + self.assertGreaterEqual(payload["memories"], 1) + self.assertGreaterEqual(payload["edges"], 1) + self.assertEqual(payload["budget"], "small") + self.assertIn("cache", payload["timings"]) + self.assertIn("search", payload["timings"]) + self.assertIn("query", payload["timings"]) + self.assertIn("graph", payload["timings"]) + self.assertGreater(payload["budget_report"]["context_packet"]["estimated_chars"], 0) + def test_brief_surfaces_saved_captures_without_secret_values(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) target = tmp / "demo" From c5568f6dfa3e79b545ab97ed7367f6c74bf2a7d2 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 21:45:16 -0600 Subject: [PATCH 145/292] Harden local server startup flags --- CHANGELOG.md | 1 + README.md | 4 ++-- SECURITY.md | 4 ++-- serve.py | 24 ++++++++++++++++++++++-- tests/test_serve.py | 8 ++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d66cda..f6ec3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Fixed missing HTTP context topics to return a controlled 400 JSON error. - Hardened backlink rebuild over HTTP so local web rebuilds require JSON POST instead of a mutating GET. - Hardened HTTP rebuild actions so local web index/backlink mutations require the explicit local-action header. +- Hardened local web startup so unsupported host/bind flags fail instead of implying public serving is supported. - Hardened `/raw/` static serving so the local web viewer only serves supported media/PDF source assets. - Tightened raw asset path resolution so `/raw/` URLs cannot route through non-raw static allowlists, including encoded parent-directory paths. - Hardened HTTP memory mutation endpoints with an explicit `X-Link-Local-Action: true` header required by non-UI clients. diff --git a/README.md b/README.md index 63f5404..0017d51 100644 --- a/README.md +++ b/README.md @@ -505,8 +505,8 @@ from other explicit projects out of recall and duplicate/conflict checks. `serve.py` exposes Link locally while the web viewer is running. -Local use only: `serve.py` binds to `127.0.0.1` and has no authentication. Do not -expose it to the internet without adding auth. HTTP write actions require +Local use only: `serve.py` binds to `127.0.0.1`, rejects host/bind flags, and +has no authentication. Do not expose it to the internet without adding auth. HTTP write actions require `X-Link-Local-Action: true`; proposal analysis does not write pages. Common endpoints: diff --git a/SECURITY.md b/SECURITY.md index a35e0c2..623380b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,8 +3,8 @@ ## Local-first threat model Link is designed for local personal knowledge management. `serve.py` binds to -`127.0.0.1` and has no authentication, so it should not be exposed directly to -the public internet. +`127.0.0.1`, rejects host/bind flags, and has no authentication, so it should +not be exposed directly to the public internet. The server and MCP package do not call external APIs, send telemetry, or require secrets. Raw sources and generated wiki pages are user data and are ignored by diff --git a/serve.py b/serve.py index e8e3759..4d12db3 100644 --- a/serve.py +++ b/serve.py @@ -3574,13 +3574,33 @@ def _file(self, fpath, content_type): def log_message(self, *a): pass +def _parse_serve_port(argv: list[str], default: int = PORT) -> int: + port = default + for index, arg in enumerate(argv): + if arg in {"--host", "--bind"} or arg.startswith("--host=") or arg.startswith("--bind="): + raise SystemExit("Link serve is local-only; host/bind options are not supported.") + if arg == "--port": + if index + 1 >= len(argv): + raise SystemExit("--port requires a value") + try: + port = int(argv[index + 1]) + except ValueError as exc: + raise SystemExit("--port must be an integer") from exc + elif arg.startswith("--port="): + try: + port = int(arg.split("=", 1)[1]) + except ValueError as exc: + raise SystemExit("--port must be an integer") from exc + return port + + def main(): global PORT - for i, a in enumerate(sys.argv[1:]): - if a == "--port" and i + 1 < len(sys.argv) - 1: PORT = int(sys.argv[i+2]) + PORT = _parse_serve_port(sys.argv[1:], default=PORT) socketserver.TCPServer.allow_reuse_address = True with socketserver.TCPServer(("127.0.0.1", PORT), Handler) as s: print(f" Link → http://localhost:{PORT}") + print(" Local-only: bound to 127.0.0.1; no public host mode.") try: s.serve_forever() except KeyboardInterrupt: print("\n stopped.") diff --git a/tests/test_serve.py b/tests/test_serve.py index 3cf5b92..e0334fb 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -114,6 +114,14 @@ def test_css_has_mobile_overflow_guards(self): self.assertIn(".memory-grid { grid-template-columns: minmax(0, 1fr); }", serve.CSS) self.assertIn(".memory-actions code, .memory-next code { word-break: break-word; }", serve.CSS) + def test_server_args_stay_local_only(self): + self.assertEqual(serve._parse_serve_port(["--port", "3010"], default=3000), 3010) + self.assertEqual(serve._parse_serve_port(["--port=3011"], default=3000), 3011) + with self.assertRaises(SystemExit): + serve._parse_serve_port(["--host", "0.0.0.0"], default=3000) + with self.assertRaises(SystemExit): + serve._parse_serve_port(["--bind=0.0.0.0"], default=3000) + def test_home_page_shows_first_agent_prompts(self): self.make_wiki() From 1309ede4b3e3f4d45c979a53f0d4aee2f7b18844 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 21:50:15 -0600 Subject: [PATCH 146/292] Add guarded web memory approvals --- CHANGELOG.md | 1 + README.md | 2 + serve.py | 132 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_serve.py | 41 ++++++++++++++ 4 files changed, 176 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ec3e9..7a76a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added Memory Review Inbox with `memory-inbox`, `review-memory`, MCP `memory_inbox`/`review_memory`, `/inbox`, and `/api/memory-inbox`. - Added Explain Memory views with `explain-memory`, MCP `explain_memory`, `/explain-memory`, and `/api/explain-memory` for provenance, review state, lifecycle, graph links, and recall readiness. - Added `/propose`, a read-only local UI for turning pasted source/session notes into memory proposals without writing pages. +- Added guarded web approval actions on `/propose` with local-only `remember-memory` and `update-memory` APIs for explicitly saving selected proposals. - Added MCP `link_status` and `/api/status` for a compact readiness summary with version, wiki path, page/memory counts, optional validation, and safe next actions. - Added `link.py status` so the same readiness summary is available before MCP or the local web server is connected. - Added `link.py status --validate` to installer next-step output so new users have one readiness command after setup. diff --git a/README.md b/README.md index 0017d51..6eb6be5 100644 --- a/README.md +++ b/README.md @@ -528,6 +528,8 @@ Common endpoints: | `GET /api/proposal-sources` | Local raw text sources that can be loaded into `/propose`; snippets are redacted when secret-looking values are present. | | `GET /api/proposal-source?path=raw/file.md` | Load one safe raw text source into the proposal workflow; secret-warning files are refused until redacted. | | `POST /api/propose-memories` | Returns memory proposals from pasted or loaded notes without writing pages; used by `/propose`. | +| `POST /api/remember-memory` | Header `X-Link-Local-Action: true`; saves an explicitly approved memory through duplicate/conflict-safe core writes. | +| `POST /api/update-memory` | Header `X-Link-Local-Action: true`; merges an approved proposal into an existing memory and resets review. | | `POST /api/review-memory` | Header `X-Link-Local-Action: true`; JSON `{ "memory": "name", "note": "optional" }`; marks a memory reviewed. | | `POST /api/archive-memory` | Header `X-Link-Local-Action: true`; JSON `{ "memory": "name", "reason": "optional" }`; hides a memory from default recall. | | `POST /api/restore-memory` | Header `X-Link-Local-Action: true`; JSON `{ "memory": "name" }`; restores an archived memory to active recall. | diff --git a/serve.py b/serve.py index 4d12db3..989ebef 100644 --- a/serve.py +++ b/serve.py @@ -27,6 +27,8 @@ normalize_project as _core_normalize_project, propose_memories_from_text as _core_propose_memories_from_text, set_memory_status as _core_set_memory_status, + update_memory_page as _core_update_memory_page, + write_memory_page as _core_write_memory_page, ) from link_core.frontmatter import ( parse_frontmatter as _parse_frontmatter, @@ -327,6 +329,47 @@ def _set_memory_status(identifier: str, status: str, reason: str = "") -> dict[s return result +def _remember_memory_from_web(payload: dict[str, object]) -> dict[str, object]: + result = _core_write_memory_page( + WIKI_DIR, + _clean_text_input(payload.get("memory") or payload.get("text"), max_len=MAX_POST_BYTES), + _clean_text_input(payload.get("title"), max_len=160) or None, + _clean_text_input(payload.get("memory_type") or payload.get("type") or "note", max_len=30), + _clean_text_input(payload.get("scope") or "user", max_len=30), + _clean_text_input(payload.get("tags"), max_len=500) or None, + _clean_text_input(payload.get("source") or "web approval", max_len=500), + _utc_timestamp(), + project=_clean_text_input(payload.get("project"), max_len=80) or None, + records=_memory_records(), + allow_duplicate=bool(payload.get("allow_duplicate")), + allow_conflict=bool(payload.get("allow_conflict")), + log_writer=_append_log, + rebuild_backlinks=lambda: bool(_rebuild_backlinks_payload().get("rebuilt")), + ) + if result.get("created"): + _invalidate_pages_cache() + return result + + +def _update_memory_from_web(payload: dict[str, object]) -> dict[str, object]: + result = _core_update_memory_page( + WIKI_DIR, + _clean_text_input(payload.get("memory") or payload.get("identifier"), max_len=300), + _clean_text_input(payload.get("text"), max_len=MAX_POST_BYTES), + _clean_text_input(payload.get("source") or "web approval", max_len=500), + _utc_timestamp(), + records=_memory_records(), + review_command="review-memory", + allow_conflict=bool(payload.get("allow_conflict")), + project=_clean_text_input(payload.get("project"), max_len=80) or None, + log_writer=_append_log, + rebuild_backlinks=lambda: bool(_rebuild_backlinks_payload().get("rebuilt")), + ) + if result.get("updated"): + _invalidate_pages_cache() + return result + + def _memory_activity_key(record: dict[str, object]) -> tuple[str, str, str]: return ( str(record.get("updated_at") or record.get("date_captured") or ""), @@ -1398,6 +1441,74 @@ def _flush_blockquote(): parent.appendChild(button); } + function firstCandidateName(items) { + if (!items || !items.length) return ''; + return items[0].name || items[0].title || ''; + } + + function approvalEndpoint(proposal) { + var action = proposal.primary_action || {}; + if (action.kind === 'remember' && !(proposal.conflict_candidates && proposal.conflict_candidates.length)) { + return '/api/remember-memory'; + } + if (action.kind === 'update' && firstCandidateName(proposal.duplicate_candidates)) { + return '/api/update-memory'; + } + return ''; + } + + function approvalPayload(proposal) { + var endpoint = approvalEndpoint(proposal); + if (endpoint === '/api/update-memory') { + return { + memory: firstCandidateName(proposal.duplicate_candidates), + text: proposal.memory || '', + source: proposal.source || 'web approval', + project: proposal.project || '' + }; + } + return { + memory: proposal.memory || '', + title: proposal.title || '', + memory_type: proposal.memory_type || 'note', + scope: proposal.scope || 'user', + source: proposal.source || 'web approval', + project: proposal.project || '' + }; + } + + function addApproveButton(parent, proposal) { + var endpoint = approvalEndpoint(proposal); + if (!endpoint) return; + var button = document.createElement('button'); + button.type = 'button'; + button.textContent = endpoint === '/api/update-memory' ? 'Approve update' : 'Approve and save'; + button.addEventListener('click', async function() { + var message = endpoint === '/api/update-memory' + ? 'Update the existing memory with this proposal?' + : 'Save this proposal as durable local memory?'; + if (!window.confirm(message)) return; + button.disabled = true; + button.textContent = 'Saving...'; + try { + var response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-Link-Local-Action': 'true'}, + body: JSON.stringify(approvalPayload(proposal)) + }); + var data = await response.json(); + if (!response.ok) throw new Error(data.error || data.message || 'memory save failed'); + button.textContent = 'Saved'; + setStatus('Saved ' + (data.title || data.name || 'memory') + '. Review it in the memory inbox.'); + } catch (error) { + button.disabled = false; + button.textContent = endpoint === '/api/update-memory' ? 'Approve update' : 'Approve and save'; + setStatus(error.message || 'memory save failed'); + } + }); + parent.appendChild(button); + } + function renderProposals(data) { if (!resultsEl) return; resultsEl.textContent = ''; @@ -1437,6 +1548,7 @@ def _flush_blockquote(): } var actions = document.createElement('div'); actions.className = 'proposal-actions'; + addApproveButton(actions, proposal); addCopyButton(actions, 'Copy approval prompt', promptText); addCopyButton(actions, 'Copy CLI command', action.command || ''); card.appendChild(actions); @@ -3272,6 +3384,26 @@ def do_POST(self): ) self._json(result) return + if path in {"/api/remember-memory", "/api/update-memory"}: + if not self._require_local_action_header({"saved": False}): + return + payload, error, status = self._read_json_body() + if error: + self._json({"saved": False, "error": error}, status=status) + return + assert payload is not None + try: + if path == "/api/remember-memory": + result = _remember_memory_from_web(payload) + http_status = 200 if result.get("created") else 409 + self._json({"saved": bool(result.get("created")), **result}, status=http_status) + else: + result = _update_memory_from_web(payload) + http_status = 200 if result.get("updated") else 409 + self._json({"saved": bool(result.get("updated")), **result}, status=http_status) + except ValueError as exc: + self._json({"saved": False, "error": str(exc)}, status=400) + return if path in {"/api/review-memory", "/api/archive-memory", "/api/restore-memory"}: if not self._require_local_action_header(): return diff --git a/tests/test_serve.py b/tests/test_serve.py index e0334fb..2dd15f9 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -913,10 +913,51 @@ def test_propose_page_renders_read_only_workflow(self): self.assertIn("Approve explicitly", html) self.assertIn("This step never writes durable memory", html) self.assertIn("Proposal-only: no durable memory has been written yet.", html) + self.assertIn("Approve and save", html) + self.assertIn("/api/remember-memory", html) + self.assertIn("/api/update-memory", html) self.assertIn("Copy approval prompt", html) self.assertIn("navigator.clipboard.writeText", html) self.assertIn("var initialSource = form.getAttribute('data-initial-source')", html) + def test_memory_approval_api_requires_header_and_writes_memory(self): + wiki = self.make_wiki() + payload = { + "memory": "User wants Link memory approvals to stay explicit.", + "title": "Explicit approvals", + "memory_type": "preference", + "scope": "user", + "source": "web proposal", + } + + denied_status, denied_payload = post_json("/api/remember-memory", payload, local_action=False) + create_status, created = post_json("/api/remember-memory", payload) + duplicate_status, duplicate = post_json("/api/remember-memory", payload) + update_status, updated = post_json( + "/api/update-memory", + { + "memory": created["name"], + "text": "User also wants the web proposal flow to preserve review.", + "source": "web proposal", + }, + ) + page_text = (wiki / "memories" / f"{created['name']}.md").read_text(encoding="utf-8") + + self.assertEqual(denied_status, 403) + self.assertIn("X-Link-Local-Action", denied_payload["error"]) + self.assertEqual(create_status, 200) + self.assertTrue(created["saved"]) + self.assertTrue(created["created"]) + self.assertEqual(created["path"], f"wiki/memories/{created['name']}.md") + self.assertEqual(duplicate_status, 409) + self.assertFalse(duplicate["saved"]) + self.assertTrue(duplicate["duplicate"]) + self.assertEqual(update_status, 200) + self.assertTrue(updated["saved"]) + self.assertTrue(updated["updated"]) + self.assertEqual(updated["review_status"], "pending") + self.assertIn("User also wants the web proposal flow", page_text) + def test_proposal_sources_api_lists_safe_raw_files(self): wiki = self.make_wiki() raw = wiki.parent / "raw" From 1613dd79e19c56e358de0dee63cbb7306f44782b Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 21:52:39 -0600 Subject: [PATCH 147/292] Expose local HTTP API version --- CHANGELOG.md | 1 + README.md | 2 +- serve.py | 6 +++++- tests/test_serve.py | 11 +++++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a76a20..ed508ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link init` to create or repair a normal Link wiki without loading demo content. - Added `link serve` to start the local web viewer without remembering `serve.py` paths. - Added `link benchmark` to measure local cache, search, smart query, and graph timings on a user's current wiki. +- Added an explicit local HTTP API version header and status field for future integration compatibility. - Added wiki schema markers with safe `link migrate`/MCP `migrate_wiki` migrations for future local format changes. - Added first-run agent prompts to installer output so new users can immediately try brief, remember, and query workflows. - Added guided `link ingest-status` output with structured JSON guidance, exact agent prompts, and follow-up validation commands. diff --git a/README.md b/README.md index 6eb6be5..2fb7e87 100644 --- a/README.md +++ b/README.md @@ -507,7 +507,7 @@ from other explicit projects out of recall and duplicate/conflict checks. Local use only: `serve.py` binds to `127.0.0.1`, rejects host/bind flags, and has no authentication. Do not expose it to the internet without adding auth. HTTP write actions require -`X-Link-Local-Action: true`; proposal analysis does not write pages. +`X-Link-Local-Action: true`; responses include `X-Link-API-Version`; proposal analysis does not write pages. Common endpoints: diff --git a/serve.py b/serve.py index 989ebef..1e59bce 100644 --- a/serve.py +++ b/serve.py @@ -74,6 +74,7 @@ WIKI_DIR = ROOT / "wiki" RAW_DIR = ROOT / "raw" PORT = 3000 +API_VERSION = "1" MAX_POST_BYTES = 64 * 1024 MAX_PROPOSAL_SOURCE_BYTES = 64 * 1024 LOCAL_ACTION_HEADER = "X-Link-Local-Action" @@ -3318,12 +3319,14 @@ def _validate_wiki_payload(strict: bool = False) -> dict[str, object]: def _link_status_payload(include_validation: bool = False) -> dict[str, object]: - return _core_link_status( + payload = _core_link_status( WIKI_DIR, cache=_current_wiki_cache(), records=_memory_records(), include_validation=include_validation, ) + payload["api_version"] = API_VERSION + return payload # --------------------------------------------------------------------------- @@ -3680,6 +3683,7 @@ def _read_json_body(self) -> tuple[dict | None, str | None, int]: return payload, None, 200 def _security_headers(self): + self.send_header("X-Link-API-Version", API_VERSION) self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Referrer-Policy", "no-referrer") self.send_header("Cross-Origin-Resource-Policy", "same-origin") diff --git a/tests/test_serve.py b/tests/test_serve.py index 2dd15f9..cdf623f 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -114,6 +114,16 @@ def test_css_has_mobile_overflow_guards(self): self.assertIn(".memory-grid { grid-template-columns: minmax(0, 1fr); }", serve.CSS) self.assertIn(".memory-actions code, .memory-next code { word-break: break-word; }", serve.CSS) + def test_security_headers_include_api_version(self): + handler = object.__new__(serve.Handler) + headers = [] + handler.send_header = lambda key, value: headers.append((key, value)) + + handler._security_headers() + + self.assertIn(("X-Link-API-Version", serve.API_VERSION), headers) + self.assertIn(("X-Content-Type-Options", "nosniff"), headers) + def test_server_args_stay_local_only(self): self.assertEqual(serve._parse_serve_port(["--port", "3010"], default=3000), 3010) self.assertEqual(serve._parse_serve_port(["--port=3011"], default=3000), 3011) @@ -520,6 +530,7 @@ def test_status_api_returns_readiness_summary(self): status, payload = run_handler("GET", "/api/status?validate=true") self.assertEqual(status, 200) + self.assertEqual(payload["api_version"], serve.API_VERSION) self.assertTrue(payload["ready"]) self.assertEqual(payload["memory_count"], 1) self.assertTrue(payload["validation"]["passed"]) From 3815d034f6ffb5e9c8efb756eb670ba8b4969aab Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:10:37 -0600 Subject: [PATCH 148/292] Add optional SQLite FTS search acceleration --- CHANGELOG.md | 1 + README.md | 4 + link.py | 2 + mcp_package/link_core/wiki.py | 145 +++++++++++++++++++++++++++++++++ mcp_package/link_mcp/server.py | 34 ++++---- scripts/smoke_large_wiki.py | 1 + serve.py | 21 ++++- tests/test_demo_snapshot.py | 7 ++ tests/test_link_cli.py | 1 + tests/test_mcp_contract.py | 2 + tests/test_serve.py | 7 ++ tests/test_wiki_core.py | 20 +++++ 12 files changed, 227 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed508ce..e914ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added estimated character/token counts to smart query budget reports so agents can reason about context cost. - Added provenance metadata to smart query memory and wiki packets so agents can explain why Link knows something without loading full pages. - Added precomputed search word indexes so repeated wiki search and smart query calls avoid rebuilding per-page word sets on larger wikis. +- Added optional in-memory SQLite FTS search acceleration with token-index fallback so large local wikis stay fast without adding a server dependency. - Improved smart query follow-ups so a truncated large-budget packet does not ask the agent to rerun the same large budget again. - Added `link.py validate` as an ingest gate for agent-generated wiki pages, covering required frontmatter, type/directory alignment, required sections, dead links, and stale backlinks. - Added MCP `validate_wiki` and `/api/validate` so agents can run the same ingest gate without shell access. diff --git a/README.md b/README.md index 2fb7e87..16e0e71 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ project constraints, and why something matters. Link makes that context durable: - **Graph context:** pages know what links in, what links out, and what sits nearby. - **MCP-native:** every local agent can use the same memory layer. - **Local-first:** no hosted backend, no telemetry, no cloud lock-in. +- **Fast local search:** optional in-memory SQLite FTS accelerates large wikis + when available; Link falls back to its token index when it is not. - **Inspectable:** Markdown files, backlinks, logs, and review states are yours. ## Quick Start @@ -587,6 +589,8 @@ Link is local-first: - No telemetry. - No hosted backend. - No external API calls from `serve.py` or `link-mcp`. +- SQLite search, when available, is an in-memory derived index. Markdown remains + the source of truth and no SQLite server is required. - Raw sources and generated wiki pages are ignored by git by default. - `link backup` and MCP `backup_wiki` write local `.link-backups/` archives; `raw/` is excluded unless you explicitly pass `--include-raw`. diff --git a/link.py b/link.py index 7ccbab0..ca0ce9d 100644 --- a/link.py +++ b/link.py @@ -2692,6 +2692,7 @@ def benchmark( "pages": len(cache.get("pages", [])), "memories": len(_memory_records(wiki_dir)), "edges": len(graph.get("edges", [])) if isinstance(graph, dict) else 0, + "search_backend": str(cache.get("search_backend") or "token-index"), "search_results": len(results) if isinstance(results, list) else 0, "context_items": len(packet.get("context_packet", [])) if isinstance(packet, dict) else 0, "found": bool(packet.get("found")) if isinstance(packet, dict) else False, @@ -2708,6 +2709,7 @@ def benchmark( print(f"Project: {project_name}") print("") print(f"Scale: {payload['pages']} pages · {payload['memories']} memories · {payload['edges']} edges") + print(f"Search backend: {payload['search_backend']}") print(f"Results: {payload['search_results']} search results · {payload['context_items']} context items") print("") print("Timings") diff --git a/mcp_package/link_core/wiki.py b/mcp_package/link_core/wiki.py index 033fa5d..181847f 100644 --- a/mcp_package/link_core/wiki.py +++ b/mcp_package/link_core/wiki.py @@ -3,6 +3,10 @@ import json import re +try: + import sqlite3 +except Exception: # pragma: no cover - depends on the host Python build + sqlite3 = None # type: ignore[assignment] from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -42,6 +46,135 @@ def _search_words(value: object) -> set[str]: return {word for word in re.split(r"\W+", normalized_search_text(value)) if len(word) >= 3} +def _search_terms(value: object) -> list[str]: + seen: set[str] = set() + terms: list[str] = [] + for word in re.split(r"\W+", normalized_search_text(value)): + if len(word) < 3 or word in seen: + continue + seen.add(word) + terms.append(word) + return terms + + +def _build_fts_index(pages: list[dict[str, Any]], fulltext: dict[str, str]) -> Any | None: + """Build an optional in-memory SQLite FTS index. + + Markdown remains the source of truth. This index is derived, local, and + rebuilt with the normal wiki cache; hosts without sqlite/FTS fall back to + the token index. + """ + if sqlite3 is None or not pages: + return None + conn = None + try: + conn = sqlite3.connect(":memory:") + conn.execute("CREATE VIRTUAL TABLE page_fts USING fts5(name UNINDEXED, title, metadata, body)") + rows = [] + for page in pages: + stem = str(page["name"]).lower() + metadata = " ".join([ + stem, + str(page.get("type") or ""), + str(page.get("category") or ""), + str(page.get("tldr") or ""), + " ".join(str(alias) for alias in page.get("aliases", [])), + " ".join(str(tag) for tag in page.get("tags", [])), + ]) + rows.append((stem, str(page.get("title") or ""), metadata, fulltext.get(stem, ""))) + conn.executemany("INSERT INTO page_fts(name, title, metadata, body) VALUES (?, ?, ?, ?)", rows) + return _FtsIndex(conn) + except Exception: + if conn is not None: + conn.close() + return None + + +def _fts_expr(terms: list[str], operator: str) -> str: + return f" {operator} ".join(f'"{term}"' for term in terms) + + +class _FtsIndex: + def __init__(self, conn: Any) -> None: + self._conn = conn + + def search(self, query: str, limit: int) -> list[str]: + terms = _search_terms(query) + if not terms: + return [] + expressions = [_fts_expr(terms, "AND")] + if len(terms) > 1: + expressions.append(_fts_expr(terms, "OR")) + for expression in expressions: + try: + rows = self._conn.execute( + "SELECT name FROM page_fts WHERE page_fts MATCH ? ORDER BY bm25(page_fts) LIMIT ?", + (expression, max(1, limit)), + ).fetchall() + except Exception: + continue + names = [str(row[0]) for row in rows] + if names: + return names + return [] + + def close(self) -> None: + conn = self._conn + if conn is None: + return + self._conn = None + conn.close() + + def __del__(self) -> None: + try: + self.close() + except Exception: + pass + + +def _fts_candidates(cache: dict[str, Any], query: str, limit: int) -> list[str]: + index = cache.get("fts_index") + if not isinstance(index, _FtsIndex): + return [] + return index.search(query, limit) + + +def _exact_search_candidates(q_lower: str, q_normalized: str, pages: list[dict[str, Any]]) -> set[str]: + candidates: set[str] = set() + for page in pages: + stem = str(page.get("name") or "").lower() + if not stem: + continue + title = str(page.get("title") or "") + if q_lower == stem or (q_normalized and q_normalized == normalized_search_text(stem)): + candidates.add(stem) + continue + if q_lower in title.lower() or (q_normalized and q_normalized in normalized_search_text(title)): + candidates.add(stem) + continue + aliases = page.get("aliases", []) + tags = page.get("tags", []) + tldr = str(page.get("tldr") or "") + if any(q_lower in str(alias).lower() or (q_normalized and q_normalized in normalized_search_text(alias)) for alias in aliases): + candidates.add(stem) + continue + if any(q_lower in str(tag).lower() or (q_normalized and q_normalized in normalized_search_text(tag)) for tag in tags): + candidates.add(stem) + continue + if q_lower in tldr.lower() or (q_normalized and q_normalized in normalized_search_text(tldr)): + candidates.add(stem) + return candidates + + +def close_wiki_cache(cache: dict[str, Any]) -> None: + index = cache.get("fts_index") if isinstance(cache, dict) else None + close = getattr(index, "close", None) + if callable(close): + close() + if isinstance(cache, dict): + cache["fts_index"] = None + + def wiki_mtime(wiki_dir: Path) -> float: """Return an mtime signal for files that affect wiki indexes.""" try: @@ -167,6 +300,7 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: " ".join(str(tag) for tag in tags_raw), ])) + fts_index = _build_fts_index(pages, fulltext) return { "mtime": wiki_mtime(wiki_dir), "pages": pages, @@ -179,6 +313,8 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: "token_index": token_index, "meta_token_index": meta_token_index, "page_map": {page["name"].lower(): page for page in pages}, + "fts_index": fts_index, + "search_backend": "sqlite-fts" if fts_index is not None else "token-index", } @@ -262,6 +398,15 @@ def search_pages(query: str, cache: dict[str, Any], limit: int = 20) -> list[dic else: candidates = {page["name"].lower() for page in pages} + candidate_cap = max(limit * 25, 200) + fts_candidates = _fts_candidates(cache, q, limit=candidate_cap) + if fts_candidates: + fts_set = set(fts_candidates) + if len(candidates) > candidate_cap: + candidates = fts_set | _exact_search_candidates(q_lower, q_normalized, pages) + else: + candidates = candidates | fts_set + scored: list[tuple[int, dict[str, Any]]] = [] for stem in candidates: page = page_map.get(stem) diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index d54a3f9..f741937 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -156,6 +156,7 @@ from link_core.wiki import ( build_backlinks as _core_build_backlinks, build_wiki_cache as _core_build_wiki_cache, + close_wiki_cache as _core_close_wiki_cache, context_for_topic as _core_context_for_topic, graph_data as _core_graph_data, load_backlinks_index as _core_load_backlinks_index, @@ -188,12 +189,20 @@ def _wiki_mtime() -> float: return _core_wiki_mtime(WIKI_DIR) +def _clear_cache() -> None: + global _cache, _cache_mtime + _core_close_wiki_cache(_cache) + _cache = {} + _cache_mtime = 0.0 + + def _build_cache() -> dict: global _cache, _cache_mtime mtime = _wiki_mtime() if _cache and mtime == _cache_mtime: return _cache + _core_close_wiki_cache(_cache) _cache = _core_build_wiki_cache(WIKI_DIR) _cache_mtime = mtime return _cache @@ -318,10 +327,8 @@ def _link_status(include_validation: bool = False) -> dict[str, object]: def _migrate_wiki() -> dict[str, object]: - global _cache, _cache_mtime payload = _core_migrate_wiki(WIKI_DIR) - _cache = {} - _cache_mtime = 0.0 + _clear_cache() return payload @@ -468,7 +475,7 @@ def _capture_session( f"Proposals: {proposals['count']}", ], ) - _cache.clear() + _clear_cache() return { "captured": True, "path": rel_path, @@ -693,7 +700,7 @@ def _set_memory_status(identifier: str, status: str, reason: str = "") -> dict[s log_writer=_append_log, ) if result["updated"]: - _cache.clear() + _clear_cache() return result @@ -708,7 +715,7 @@ def _forget_memory(identifier: str, confirm: bool = False) -> dict[str, object]: rebuild_backlinks=_rebuild_memory_backlinks, ) if result.get("forgotten"): - _cache.clear() + _clear_cache() return result @@ -723,7 +730,7 @@ def _mark_memory_reviewed(identifier: str, note: str = "") -> dict[str, object]: log_writer=_append_log, ) if result["updated"]: - _cache.clear() + _clear_cache() return result @@ -744,7 +751,7 @@ def _update_memory_page( allow_conflict=allow_conflict, **options, ) - _cache.clear() + _clear_cache() return result @@ -765,7 +772,7 @@ def _write_memory_page( **options, ) if result.get("created"): - _cache.clear() + _clear_cache() return result @@ -1278,10 +1285,8 @@ def rebuild_index() -> str: Run this after ingesting sources or making large page edits so the human-readable wiki catalog reflects all pages grouped by category. """ - global _cache, _cache_mtime result = _core_rebuild_index(WIKI_DIR, cache=_build_cache()) - _cache = {} - _cache_mtime = 0.0 + _clear_cache() return json.dumps(result, ensure_ascii=False) @@ -1297,10 +1302,7 @@ def rebuild_backlinks() -> str: bl_path = WIKI_DIR / "_backlinks.json" bl_path.write_text(json.dumps(result, indent=2), encoding="utf-8") - # Invalidate cache - global _cache, _cache_mtime - _cache = {} - _cache_mtime = 0.0 + _clear_cache() return json.dumps({"rebuilt": True, "pages_indexed": len(result["backlinks"])}) diff --git a/scripts/smoke_large_wiki.py b/scripts/smoke_large_wiki.py index a56a93f..98b48ff 100644 --- a/scripts/smoke_large_wiki.py +++ b/scripts/smoke_large_wiki.py @@ -166,6 +166,7 @@ def run_smoke(work_dir: Path, page_count: int, max_seconds: dict[str, float] | N "wiki": str(wiki), "pages": len(cache["pages"]), "edges": len(graph["edges"]), + "search_backend": str(cache.get("search_backend") or "token-index"), "context_items": len(packet.get("context_packet", [])), "search_results": len(results), "timings": {key: round(value, 4) for key, value in timings.items()}, diff --git a/serve.py b/serve.py index 1e59bce..cfa1a87 100644 --- a/serve.py +++ b/serve.py @@ -62,6 +62,7 @@ from link_core.wiki import ( build_backlinks as _core_build_backlinks, build_wiki_cache as _core_build_wiki_cache, + close_wiki_cache as _core_close_wiki_cache, context_for_topic as _core_context_for_topic, graph_data as _core_graph_data, load_backlinks_index as _core_load_backlinks_index, @@ -109,15 +110,22 @@ _page_index: dict[str, Path] = {} # stem.lower() → path _fulltext_index: dict[str, str] = {} # stem.lower() → full text (for search) _normalized_fulltext_index: dict[str, str] = {} # punctuation-normalized full text +_text_words_index: dict[str, set[str]] = {} # stem.lower() → normalized fulltext words +_meta_words_index: dict[str, set[str]] = {} # stem.lower() → normalized metadata words _snippet_index: dict[str, str] = {} # stem.lower() → pre-extracted first snippet _token_index: dict[str, set[str]] = {} # token → set of page stems that contain it _page_map: dict[str, dict] = {} # stem.lower() → page dict (for O(1) lookup in search) _meta_token_index: dict[str, set[str]] = {} # token → stems with that token in title/alias/tag/tldr +_fts_index = None +_search_backend = "token-index" def _invalidate_pages_cache() -> None: - global _pages_cache, _pages_cache_mtime + global _pages_cache, _pages_cache_mtime, _fts_index, _search_backend + _core_close_wiki_cache({"fts_index": _fts_index}) _pages_cache = None _pages_cache_mtime = 0.0 + _fts_index = None + _search_backend = "token-index" def _wiki_mtime() -> float: @@ -125,20 +133,25 @@ def _wiki_mtime() -> float: def _get_all_pages() -> list: - global _pages_cache, _pages_cache_mtime, _page_index, _fulltext_index, _normalized_fulltext_index, _snippet_index, _token_index, _page_map, _meta_token_index + global _pages_cache, _pages_cache_mtime, _page_index, _fulltext_index, _normalized_fulltext_index, _text_words_index, _meta_words_index, _snippet_index, _token_index, _page_map, _meta_token_index, _fts_index, _search_backend mtime = _wiki_mtime() if _pages_cache is not None and mtime == _pages_cache_mtime: return _pages_cache + _core_close_wiki_cache({"fts_index": _fts_index}) cache = _core_build_wiki_cache(WIKI_DIR) _pages_cache = cache["pages"] _pages_cache_mtime = mtime _page_index = cache["page_index"] _fulltext_index = cache["fulltext"] _normalized_fulltext_index = cache["normalized_fulltext"] + _text_words_index = cache["text_words_index"] + _meta_words_index = cache["meta_words_index"] _snippet_index = cache["snippet_index"] _token_index = cache["token_index"] _meta_token_index = cache["meta_token_index"] _page_map = cache["page_map"] + _fts_index = cache.get("fts_index") + _search_backend = str(cache.get("search_backend") or "token-index") return _pages_cache @@ -149,10 +162,14 @@ def _current_wiki_cache() -> dict[str, object]: "page_index": _page_index, "fulltext": _fulltext_index, "normalized_fulltext": _normalized_fulltext_index, + "text_words_index": _text_words_index, + "meta_words_index": _meta_words_index, "snippet_index": _snippet_index, "token_index": _token_index, "meta_token_index": _meta_token_index, "page_map": _page_map, + "fts_index": _fts_index, + "search_backend": _search_backend, } diff --git a/tests/test_demo_snapshot.py b/tests/test_demo_snapshot.py index 897c3fa..7f5f9c4 100644 --- a/tests/test_demo_snapshot.py +++ b/tests/test_demo_snapshot.py @@ -58,6 +58,9 @@ def create_demo_quiet(target: Path) -> None: def reset_serve_wiki(wiki_dir: Path) -> None: + close = getattr(getattr(serve, "_fts_index", None), "close", None) + if callable(close): + close() serve.WIKI_DIR = wiki_dir serve.RAW_DIR = wiki_dir.parent / "raw" serve._pages_cache = None @@ -65,10 +68,14 @@ def reset_serve_wiki(wiki_dir: Path) -> None: serve._page_index = {} serve._fulltext_index = {} serve._normalized_fulltext_index = {} + serve._text_words_index = {} + serve._meta_words_index = {} serve._snippet_index = {} serve._token_index = {} serve._page_map = {} serve._meta_token_index = {} + serve._fts_index = None + serve._search_backend = "token-index" class DemoSnapshotTests(unittest.TestCase): diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 2f2344f..71d9a70 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -782,6 +782,7 @@ def test_benchmark_reports_local_query_timings(self): self.assertGreaterEqual(payload["pages"], 1) self.assertGreaterEqual(payload["memories"], 1) self.assertGreaterEqual(payload["edges"], 1) + self.assertIn(payload["search_backend"], {"sqlite-fts", "token-index"}) self.assertEqual(payload["budget"], "small") self.assertIn("cache", payload["timings"]) self.assertIn("search", payload["timings"]) diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 5da9de9..ebe74bd 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -88,6 +88,8 @@ def setUp(self): self.server, self.previous_modules, self.previous_argv, self.module_name = import_mcp_server(self.target / "wiki") def tearDown(self): + if hasattr(self.server, "_clear_cache"): + self.server._clear_cache() sys.modules.pop(self.module_name, None) restore_mcp_modules(self.previous_modules) sys.argv = self.previous_argv diff --git a/tests/test_serve.py b/tests/test_serve.py index cdf623f..f44a567 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -11,6 +11,9 @@ def reset_wiki(wiki_dir: Path) -> None: + close = getattr(getattr(serve, "_fts_index", None), "close", None) + if callable(close): + close() serve.WIKI_DIR = wiki_dir serve.RAW_DIR = wiki_dir.parent / "raw" serve._pages_cache = None @@ -18,10 +21,14 @@ def reset_wiki(wiki_dir: Path) -> None: serve._page_index = {} serve._fulltext_index = {} serve._normalized_fulltext_index = {} + serve._text_words_index = {} + serve._meta_words_index = {} serve._snippet_index = {} serve._token_index = {} serve._page_map = {} serve._meta_token_index = {} + serve._fts_index = None + serve._search_backend = "token-index" def write_page(wiki_dir: Path, rel: str, text: str) -> Path: diff --git a/tests/test_wiki_core.py b/tests/test_wiki_core.py index 13cb6e3..3d28a7e 100644 --- a/tests/test_wiki_core.py +++ b/tests/test_wiki_core.py @@ -79,6 +79,9 @@ def test_cache_search_context_and_graph(self): self.assertEqual(search[0]["name"], "agent-memory") self.assertIn("date_published", search[0]) + self.assertIn(cache["search_backend"], {"sqlite-fts", "token-index"}) + if cache["search_backend"] == "sqlite-fts": + self.assertIsNotNone(cache["fts_index"]) self.assertIn("durable", cache["meta_words_index"]["agent-memory"]) self.assertIn("references", cache["text_words_index"]["link"]) self.assertEqual(context["primary"], "agent-memory") @@ -118,6 +121,23 @@ def test_multi_token_search_uses_token_relevance_without_exact_phrase(self): self.assertNotIn("agent-only", {result["name"] for result in results}) self.assertNotIn("memory-only", {result["name"] for result in results}) + def test_search_falls_back_without_optional_fts_index(self): + wiki = self.make_wiki() + write_page( + wiki, + "concepts/agent-memory.md", + "---\ntype: concept\ntitle: Agent Memory\n---\n\n" + "# Agent Memory\n\n" + "Source-backed local memory for agents.\n", + ) + cache = build_wiki_cache(wiki) + cache["fts_index"] = None + cache["search_backend"] = "token-index" + + results = search_pages("local memory", cache, limit=5) + + self.assertEqual(results[0]["name"], "agent-memory") + def test_backlinks_loader_and_builder_shapes(self): wiki = self.make_wiki() write_page( From 54b1fb73e4e8a5fa0b3a00cfbe3cd52b98a394de Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:16:03 -0600 Subject: [PATCH 149/292] Extract shared search engine core --- CHANGELOG.md | 1 + mcp_package/link_core/search.py | 244 +++++++++++++++++++++++++++++++ mcp_package/link_core/wiki.py | 252 ++------------------------------ 3 files changed, 255 insertions(+), 242 deletions(-) create mode 100644 mcp_package/link_core/search.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e914ca3..94c5302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Extracted shared memory lifecycle mutations for archive, restore, review, and update workflows into `link_core`. - Extracted shared memory creation for `remember` and `remember_memory` into `link_core`. - Extracted shared wiki indexing, search, context, graph, and backlink helpers into `link_core`. +- Extracted shared search ranking and optional SQLite FTS helpers into `link_core.search` so wiki indexing stays separate from search execution. - Extracted shared memory explanation/audit payloads into `link_core`. ### Fixed diff --git a/mcp_package/link_core/search.py b/mcp_package/link_core/search.py new file mode 100644 index 0000000..28090dd --- /dev/null +++ b/mcp_package/link_core/search.py @@ -0,0 +1,244 @@ +"""Shared search indexing and ranking helpers for Link.""" +from __future__ import annotations + +import re +try: + import sqlite3 +except Exception: # pragma: no cover - depends on the host Python build + sqlite3 = None # type: ignore[assignment] +from typing import Any + + +def normalized_search_text(value: object) -> str: + """Normalize punctuation differences so natural queries match page slugs.""" + text = str(value).lower() + text = re.sub(r"[^a-z0-9]+", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def search_words(value: object) -> set[str]: + return {word for word in re.split(r"\W+", normalized_search_text(value)) if len(word) >= 3} + + +def _search_terms(value: object) -> list[str]: + seen: set[str] = set() + terms: list[str] = [] + for word in re.split(r"\W+", normalized_search_text(value)): + if len(word) < 3 or word in seen: + continue + seen.add(word) + terms.append(word) + return terms + + +def build_fts_index(pages: list[dict[str, Any]], fulltext: dict[str, str]) -> Any | None: + """Build an optional in-memory SQLite FTS index. + + Markdown remains the source of truth. This index is derived, local, and + rebuilt with the normal wiki cache; hosts without sqlite/FTS fall back to + the token index. + """ + if sqlite3 is None or not pages: + return None + conn = None + try: + conn = sqlite3.connect(":memory:") + conn.execute("CREATE VIRTUAL TABLE page_fts USING fts5(name UNINDEXED, title, metadata, body)") + rows = [] + for page in pages: + stem = str(page["name"]).lower() + metadata = " ".join([ + stem, + str(page.get("type") or ""), + str(page.get("category") or ""), + str(page.get("tldr") or ""), + " ".join(str(alias) for alias in page.get("aliases", [])), + " ".join(str(tag) for tag in page.get("tags", [])), + ]) + rows.append((stem, str(page.get("title") or ""), metadata, fulltext.get(stem, ""))) + conn.executemany("INSERT INTO page_fts(name, title, metadata, body) VALUES (?, ?, ?, ?)", rows) + return _FtsIndex(conn) + except Exception: + if conn is not None: + conn.close() + return None + + +def _fts_expr(terms: list[str], operator: str) -> str: + return f" {operator} ".join(f'"{term}"' for term in terms) + + +class _FtsIndex: + def __init__(self, conn: Any) -> None: + self._conn = conn + + def search(self, query: str, limit: int) -> list[str]: + terms = _search_terms(query) + if not terms: + return [] + expressions = [_fts_expr(terms, "AND")] + if len(terms) > 1: + expressions.append(_fts_expr(terms, "OR")) + for expression in expressions: + try: + rows = self._conn.execute( + "SELECT name FROM page_fts WHERE page_fts MATCH ? ORDER BY bm25(page_fts) LIMIT ?", + (expression, max(1, limit)), + ).fetchall() + except Exception: + continue + names = [str(row[0]) for row in rows] + if names: + return names + return [] + + def close(self) -> None: + conn = self._conn + if conn is None: + return + self._conn = None + conn.close() + + def __del__(self) -> None: + try: + self.close() + except Exception: + pass + + +def _fts_candidates(cache: dict[str, Any], query: str, limit: int) -> list[str]: + index = cache.get("fts_index") + if not isinstance(index, _FtsIndex): + return [] + return index.search(query, limit) + + +def _exact_search_candidates(q_lower: str, q_normalized: str, pages: list[dict[str, Any]]) -> set[str]: + candidates: set[str] = set() + for page in pages: + stem = str(page.get("name") or "").lower() + if not stem: + continue + title = str(page.get("title") or "") + if q_lower == stem or (q_normalized and q_normalized == normalized_search_text(stem)): + candidates.add(stem) + continue + if q_lower in title.lower() or (q_normalized and q_normalized in normalized_search_text(title)): + candidates.add(stem) + continue + aliases = page.get("aliases", []) + tags = page.get("tags", []) + tldr = str(page.get("tldr") or "") + if any(q_lower in str(alias).lower() or (q_normalized and q_normalized in normalized_search_text(alias)) for alias in aliases): + candidates.add(stem) + continue + if any(q_lower in str(tag).lower() or (q_normalized and q_normalized in normalized_search_text(tag)) for tag in tags): + candidates.add(stem) + continue + if q_lower in tldr.lower() or (q_normalized and q_normalized in normalized_search_text(tldr)): + candidates.add(stem) + return candidates + + +def close_wiki_cache(cache: dict[str, Any]) -> None: + index = cache.get("fts_index") if isinstance(cache, dict) else None + close = getattr(index, "close", None) + if callable(close): + close() + if isinstance(cache, dict): + cache["fts_index"] = None + + +def search_pages(query: str, cache: dict[str, Any], limit: int = 20) -> list[dict[str, Any]]: + q = query.strip() + if not q: + return [] + q_lower = q.lower() + q_normalized = normalized_search_text(q) + query_tokens = [token for token in re.split(r"\W+", q_lower) if len(token) >= 3] + pages = cache["pages"] + page_map = cache["page_map"] + token_index = cache["token_index"] + meta_token_index = cache["meta_token_index"] + fulltext = cache["fulltext"] + normalized_fulltext = cache.get("normalized_fulltext", {}) + text_words_index = cache.get("text_words_index", {}) + meta_words_index = cache.get("meta_words_index", {}) + snippet_index = cache["snippet_index"] + + is_single_token = bool(re.match(r"^\w+$", q_lower)) + if is_single_token and q_lower in token_index: + candidates = token_index[q_lower] | meta_token_index.get(q_lower, set()) + elif query_tokens: + token_sets = [ + token_index.get(token, set()) | meta_token_index.get(token, set()) + for token in query_tokens + if token in token_index or token in meta_token_index + ] + if token_sets: + intersection = set.intersection(*token_sets) + candidates = intersection if intersection else set.union(*token_sets) + else: + candidates = {page["name"].lower() for page in pages} + else: + candidates = {page["name"].lower() for page in pages} + + candidate_cap = max(limit * 25, 200) + fts_candidates = _fts_candidates(cache, q, limit=candidate_cap) + if fts_candidates: + fts_set = set(fts_candidates) + if len(candidates) > candidate_cap: + candidates = fts_set | _exact_search_candidates(q_lower, q_normalized, pages) + else: + candidates = candidates | fts_set + + scored: list[tuple[int, dict[str, Any]]] = [] + for stem in candidates: + page = page_map.get(stem) + if not page: + continue + score = 0 + title_normalized = normalized_search_text(page["title"]) + stem_normalized = normalized_search_text(stem) + aliases = page.get("aliases", []) + tags = page.get("tags", []) + tldr = page.get("tldr", "") + text_lower = fulltext.get(stem, "") + meta_words = meta_words_index.get(stem) + if meta_words is None: + meta_words = search_words(" ".join([ + str(page["title"]), + stem, + str(tldr), + " ".join(str(alias) for alias in aliases), + " ".join(str(tag) for tag in tags), + ])) + + if q_lower in str(page["title"]).lower() or (q_normalized and q_normalized in title_normalized): + score += 10 + if q_lower == stem or (q_normalized and q_normalized == stem_normalized): + score += 20 + if any(q_lower in alias or (q_normalized and q_normalized in normalized_search_text(alias)) for alias in aliases): + score += 8 + if any(q_lower in str(tag).lower() or (q_normalized and q_normalized in normalized_search_text(tag)) for tag in tags): + score += 5 + if q_lower in str(tldr).lower() or (q_normalized and q_normalized in normalized_search_text(tldr)): + score += 3 + text_normalized = normalized_fulltext.get(stem, "") + if text_lower and (q_lower in text_lower or (q_normalized and q_normalized in text_normalized)): + score += 2 + if query_tokens and all(token in meta_words for token in query_tokens): + score += 6 + elif query_tokens and any(token in meta_words for token in query_tokens): + score += 1 + if query_tokens and text_normalized: + text_words = text_words_index.get(stem) + if text_words is None: + text_words = search_words(text_normalized) + if all(token in text_words for token in query_tokens): + score += 2 + if score > 0: + scored.append((score, {**page, "score": score, "snippet": snippet_index.get(stem, "")})) + + scored.sort(key=lambda item: (-item[0], str(item[1]["title"]).lower())) + return [record for _, record in scored[:limit]] diff --git a/mcp_package/link_core/wiki.py b/mcp_package/link_core/wiki.py index 181847f..b866f18 100644 --- a/mcp_package/link_core/wiki.py +++ b/mcp_package/link_core/wiki.py @@ -3,15 +3,18 @@ import json import re -try: - import sqlite3 -except Exception: # pragma: no cover - depends on the host Python build - sqlite3 = None # type: ignore[assignment] from datetime import datetime, timezone from pathlib import Path from typing import Any from .frontmatter import parse_frontmatter +from .search import ( + build_fts_index, + close_wiki_cache, + normalized_search_text, + search_pages, + search_words, +) WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]*)?\]\]") @@ -35,146 +38,6 @@ } -def normalized_search_text(value: object) -> str: - """Normalize punctuation differences so natural queries match page slugs.""" - text = str(value).lower() - text = re.sub(r"[^a-z0-9]+", " ", text) - return re.sub(r"\s+", " ", text).strip() - - -def _search_words(value: object) -> set[str]: - return {word for word in re.split(r"\W+", normalized_search_text(value)) if len(word) >= 3} - - -def _search_terms(value: object) -> list[str]: - seen: set[str] = set() - terms: list[str] = [] - for word in re.split(r"\W+", normalized_search_text(value)): - if len(word) < 3 or word in seen: - continue - seen.add(word) - terms.append(word) - return terms - - -def _build_fts_index(pages: list[dict[str, Any]], fulltext: dict[str, str]) -> Any | None: - """Build an optional in-memory SQLite FTS index. - - Markdown remains the source of truth. This index is derived, local, and - rebuilt with the normal wiki cache; hosts without sqlite/FTS fall back to - the token index. - """ - if sqlite3 is None or not pages: - return None - conn = None - try: - conn = sqlite3.connect(":memory:") - conn.execute("CREATE VIRTUAL TABLE page_fts USING fts5(name UNINDEXED, title, metadata, body)") - rows = [] - for page in pages: - stem = str(page["name"]).lower() - metadata = " ".join([ - stem, - str(page.get("type") or ""), - str(page.get("category") or ""), - str(page.get("tldr") or ""), - " ".join(str(alias) for alias in page.get("aliases", [])), - " ".join(str(tag) for tag in page.get("tags", [])), - ]) - rows.append((stem, str(page.get("title") or ""), metadata, fulltext.get(stem, ""))) - conn.executemany("INSERT INTO page_fts(name, title, metadata, body) VALUES (?, ?, ?, ?)", rows) - return _FtsIndex(conn) - except Exception: - if conn is not None: - conn.close() - return None - - -def _fts_expr(terms: list[str], operator: str) -> str: - return f" {operator} ".join(f'"{term}"' for term in terms) - - -class _FtsIndex: - def __init__(self, conn: Any) -> None: - self._conn = conn - - def search(self, query: str, limit: int) -> list[str]: - terms = _search_terms(query) - if not terms: - return [] - expressions = [_fts_expr(terms, "AND")] - if len(terms) > 1: - expressions.append(_fts_expr(terms, "OR")) - for expression in expressions: - try: - rows = self._conn.execute( - "SELECT name FROM page_fts WHERE page_fts MATCH ? ORDER BY bm25(page_fts) LIMIT ?", - (expression, max(1, limit)), - ).fetchall() - except Exception: - continue - names = [str(row[0]) for row in rows] - if names: - return names - return [] - - def close(self) -> None: - conn = self._conn - if conn is None: - return - self._conn = None - conn.close() - - def __del__(self) -> None: - try: - self.close() - except Exception: - pass - - -def _fts_candidates(cache: dict[str, Any], query: str, limit: int) -> list[str]: - index = cache.get("fts_index") - if not isinstance(index, _FtsIndex): - return [] - return index.search(query, limit) - - -def _exact_search_candidates(q_lower: str, q_normalized: str, pages: list[dict[str, Any]]) -> set[str]: - candidates: set[str] = set() - for page in pages: - stem = str(page.get("name") or "").lower() - if not stem: - continue - title = str(page.get("title") or "") - if q_lower == stem or (q_normalized and q_normalized == normalized_search_text(stem)): - candidates.add(stem) - continue - if q_lower in title.lower() or (q_normalized and q_normalized in normalized_search_text(title)): - candidates.add(stem) - continue - aliases = page.get("aliases", []) - tags = page.get("tags", []) - tldr = str(page.get("tldr") or "") - if any(q_lower in str(alias).lower() or (q_normalized and q_normalized in normalized_search_text(alias)) for alias in aliases): - candidates.add(stem) - continue - if any(q_lower in str(tag).lower() or (q_normalized and q_normalized in normalized_search_text(tag)) for tag in tags): - candidates.add(stem) - continue - if q_lower in tldr.lower() or (q_normalized and q_normalized in normalized_search_text(tldr)): - candidates.add(stem) - return candidates - - -def close_wiki_cache(cache: dict[str, Any]) -> None: - index = cache.get("fts_index") if isinstance(cache, dict) else None - close = getattr(index, "close", None) - if callable(close): - close() - if isinstance(cache, dict): - cache["fts_index"] = None - - def wiki_mtime(wiki_dir: Path) -> float: """Return an mtime signal for files that affect wiki indexes.""" try: @@ -267,7 +130,7 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: fulltext[stem] = text_lower text_normalized = normalized_search_text(text_lower) normalized_fulltext[stem] = text_normalized - text_words_index[stem] = _search_words(text_normalized) + text_words_index[stem] = search_words(text_normalized) snippet_index[stem] = _body_snippet(body) for token in re.split(r"\W+", text_lower): @@ -292,7 +155,7 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: meta_tokens.add(word) for token in meta_tokens: meta_token_index.setdefault(token, set()).add(stem) - meta_words_index[stem] = _search_words(" ".join([ + meta_words_index[stem] = search_words(" ".join([ title, stem, tldr, @@ -300,7 +163,7 @@ def build_wiki_cache(wiki_dir: Path) -> dict[str, Any]: " ".join(str(tag) for tag in tags_raw), ])) - fts_index = _build_fts_index(pages, fulltext) + fts_index = build_fts_index(pages, fulltext) return { "mtime": wiki_mtime(wiki_dir), "pages": pages, @@ -364,101 +227,6 @@ def build_backlinks(wiki_dir: Path, body_only: bool = True) -> dict[str, dict[st return {"backlinks": backlinks, "forward": forward_links} -def search_pages(query: str, cache: dict[str, Any], limit: int = 20) -> list[dict[str, Any]]: - q = query.strip() - if not q: - return [] - q_lower = q.lower() - q_normalized = normalized_search_text(q) - query_tokens = [token for token in re.split(r"\W+", q_lower) if len(token) >= 3] - pages = cache["pages"] - page_map = cache["page_map"] - token_index = cache["token_index"] - meta_token_index = cache["meta_token_index"] - fulltext = cache["fulltext"] - normalized_fulltext = cache.get("normalized_fulltext", {}) - text_words_index = cache.get("text_words_index", {}) - meta_words_index = cache.get("meta_words_index", {}) - snippet_index = cache["snippet_index"] - - is_single_token = bool(re.match(r"^\w+$", q_lower)) - if is_single_token and q_lower in token_index: - candidates = token_index[q_lower] | meta_token_index.get(q_lower, set()) - elif query_tokens: - token_sets = [ - token_index.get(token, set()) | meta_token_index.get(token, set()) - for token in query_tokens - if token in token_index or token in meta_token_index - ] - if token_sets: - intersection = set.intersection(*token_sets) - candidates = intersection if intersection else set.union(*token_sets) - else: - candidates = {page["name"].lower() for page in pages} - else: - candidates = {page["name"].lower() for page in pages} - - candidate_cap = max(limit * 25, 200) - fts_candidates = _fts_candidates(cache, q, limit=candidate_cap) - if fts_candidates: - fts_set = set(fts_candidates) - if len(candidates) > candidate_cap: - candidates = fts_set | _exact_search_candidates(q_lower, q_normalized, pages) - else: - candidates = candidates | fts_set - - scored: list[tuple[int, dict[str, Any]]] = [] - for stem in candidates: - page = page_map.get(stem) - if not page: - continue - score = 0 - title_normalized = normalized_search_text(page["title"]) - stem_normalized = normalized_search_text(stem) - aliases = page.get("aliases", []) - tags = page.get("tags", []) - tldr = page.get("tldr", "") - text_lower = fulltext.get(stem, "") - meta_words = meta_words_index.get(stem) - if meta_words is None: - meta_words = _search_words(" ".join([ - str(page["title"]), - stem, - str(tldr), - " ".join(str(alias) for alias in aliases), - " ".join(str(tag) for tag in tags), - ])) - - if q_lower in str(page["title"]).lower() or (q_normalized and q_normalized in title_normalized): - score += 10 - if q_lower == stem or (q_normalized and q_normalized == stem_normalized): - score += 20 - if any(q_lower in alias or (q_normalized and q_normalized in normalized_search_text(alias)) for alias in aliases): - score += 8 - if any(q_lower in str(tag).lower() or (q_normalized and q_normalized in normalized_search_text(tag)) for tag in tags): - score += 5 - if q_lower in str(tldr).lower() or (q_normalized and q_normalized in normalized_search_text(tldr)): - score += 3 - text_normalized = normalized_fulltext.get(stem, "") - if text_lower and (q_lower in text_lower or (q_normalized and q_normalized in text_normalized)): - score += 2 - if query_tokens and all(token in meta_words for token in query_tokens): - score += 6 - elif query_tokens and any(token in meta_words for token in query_tokens): - score += 1 - if query_tokens and text_normalized: - text_words = text_words_index.get(stem) - if text_words is None: - text_words = _search_words(text_normalized) - if all(token in text_words for token in query_tokens): - score += 2 - if score > 0: - scored.append((score, {**page, "score": score, "snippet": snippet_index.get(stem, "")})) - - scored.sort(key=lambda item: (-item[0], str(item[1]["title"]).lower())) - return [record for _, record in scored[:limit]] - - def context_for_topic( wiki_dir: Path, topic: str, From 192e1bea1d3ce1288d5cdbf44c5ca57fedaf2975 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:19:34 -0600 Subject: [PATCH 150/292] Report search backend in status --- CHANGELOG.md | 1 + README.md | 2 +- link.py | 1 + mcp_package/link_core/status.py | 17 +++++++++++++++-- tests/test_link_cli.py | 2 ++ tests/test_mcp_contract.py | 1 + tests/test_serve.py | 1 + tests/test_status_core.py | 2 ++ 8 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c5302..79a86cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `/propose`, a read-only local UI for turning pasted source/session notes into memory proposals without writing pages. - Added guarded web approval actions on `/propose` with local-only `remember-memory` and `update-memory` APIs for explicitly saving selected proposals. - Added MCP `link_status` and `/api/status` for a compact readiness summary with version, wiki path, page/memory counts, optional validation, and safe next actions. +- Added search backend reporting to Link status payloads so agents and users can see whether local search is using SQLite FTS or the token fallback. - Added `link.py status` so the same readiness summary is available before MCP or the local web server is connected. - Added `link.py status --validate` to installer next-step output so new users have one readiness command after setup. - Added a managed `~/.local/bin/link` command for global installs so users can run `link status --validate`, `link query`, and `link brief` without remembering wiki paths. diff --git a/README.md b/README.md index 16e0e71..6a5c2e0 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,7 @@ Most agents should start with: | Tool | Use it when | |------|-------------| -| `link_status` | You are connecting to Link or troubleshooting setup and need version, readiness, counts, validation summary, and safe next actions. | +| `link_status` | You are connecting to Link or troubleshooting setup and need version, readiness, counts, search backend, validation summary, and safe next actions. | | `migrate_wiki` | `link_status` reports a missing or old schema marker and you need a safe, idempotent local migration. | | `ingest_status` | The user dropped files into `raw/` or asks to ingest, and you need pending raw files plus the next prompt/checks and guided ingest plan. | | `query_link` | You need one compact, answer-ready packet that combines relevant memory, ranked wiki results, graph context, provenance, budget limits, estimated packet size, and follow-up actions without reading the whole wiki. | diff --git a/link.py b/link.py index ca0ce9d..f5f0c62 100644 --- a/link.py +++ b/link.py @@ -1488,6 +1488,7 @@ def status(target: Path, include_validation: bool = False, json_output: bool = F f"{payload['active_memory_count']} active · " f"{payload['needs_review_count']} need review" ) + print(f"Search backend: {payload.get('search_backend', 'unknown')}") schema = payload.get("schema") or {} if isinstance(schema, dict): schema_status = schema.get("status", "unknown") diff --git a/mcp_package/link_core/status.py b/mcp_package/link_core/status.py index 53b58b5..855ff82 100644 --- a/mcp_package/link_core/status.py +++ b/mcp_package/link_core/status.py @@ -7,7 +7,7 @@ from .memory import memory_records from .schema import schema_status from .validation import validate_wiki -from .wiki import build_wiki_cache +from .wiki import build_wiki_cache, close_wiki_cache def _action(label: str, tool: str, arguments: dict[str, object] | None = None) -> dict[str, object]: @@ -38,11 +38,23 @@ def link_status( missing = [name for name, path in required_paths.items() if not path.exists()] pages: list[Mapping[str, object]] = [] record_list: list[Mapping[str, object]] = [] + search_backend = "unavailable" if wiki_dir.exists(): + wiki_cache: Mapping[str, Any] | None = None + owns_cache = False try: - pages = list((cache or build_wiki_cache(wiki_dir)).get("pages", [])) + if cache is None: + wiki_cache = build_wiki_cache(wiki_dir) + owns_cache = True + else: + wiki_cache = cache + pages = list(wiki_cache.get("pages", [])) + search_backend = str(wiki_cache.get("search_backend") or "token-index") except Exception: pages = [] + finally: + if owns_cache and isinstance(wiki_cache, dict): + close_wiki_cache(wiki_cache) try: record_list = list(records if records is not None else memory_records(wiki_dir)) except Exception: @@ -98,6 +110,7 @@ def link_status( "memory_count": len(record_list), "active_memory_count": active_memory_count, "needs_review_count": needs_review_count, + "search_backend": search_backend, "schema": schema, "validation": validation_summary, "next_actions": next_actions, diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 71d9a70..2198358 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -277,6 +277,7 @@ def test_status_reports_demo_readiness(self): self.assertEqual(code, 0) self.assertIn("Ready: yes", out.getvalue()) self.assertIn("Schema: current", out.getvalue()) + self.assertIn("Search backend:", out.getvalue()) self.assertIn("Validation: passed", out.getvalue()) self.assertIn("query_link", out.getvalue()) @@ -362,6 +363,7 @@ def test_status_json_reports_missing_structure(self): self.assertEqual(code, 1) self.assertFalse(payload["ready"]) self.assertIn("wiki", payload["missing"]) + self.assertEqual(payload["search_backend"], "unavailable") def test_validate_accepts_demo_wiki(self): tmp = Path(tempfile.mkdtemp(prefix="link-validate-test-")) diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index ebe74bd..2516db5 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -135,6 +135,7 @@ def test_link_status_contract(self): self.assertTrue(payload["ready"]) self.assertEqual(payload["page_count"], 13) self.assertEqual(payload["memory_count"], 1) + self.assertIn(payload["search_backend"], {"sqlite-fts", "token-index"}) self.assertEqual(payload["schema"]["status"], "current") self.assertTrue(payload["validation"]["passed"]) self.assertEqual(payload["next_actions"][0]["tool"], "query_link") diff --git a/tests/test_serve.py b/tests/test_serve.py index f44a567..69dad02 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -540,6 +540,7 @@ def test_status_api_returns_readiness_summary(self): self.assertEqual(payload["api_version"], serve.API_VERSION) self.assertTrue(payload["ready"]) self.assertEqual(payload["memory_count"], 1) + self.assertIn(payload["search_backend"], {"sqlite-fts", "token-index"}) self.assertTrue(payload["validation"]["passed"]) self.assertEqual(payload["next_actions"][0]["tool"], "query_link") diff --git a/tests/test_status_core.py b/tests/test_status_core.py index 47f3c3b..9f8db9b 100644 --- a/tests/test_status_core.py +++ b/tests/test_status_core.py @@ -59,6 +59,7 @@ def test_link_status_reports_ready_wiki(self): self.assertEqual(payload["page_count"], 3) self.assertEqual(payload["memory_count"], 1) self.assertEqual(payload["active_memory_count"], 1) + self.assertIn(payload["search_backend"], {"sqlite-fts", "token-index"}) self.assertEqual(payload["schema"]["status"], "current") self.assertTrue(payload["validation"]["passed"]) self.assertEqual(payload["next_actions"][0]["tool"], "query_link") @@ -72,6 +73,7 @@ def test_link_status_reports_missing_structure(self): self.assertIn("wiki", payload["missing"]) self.assertEqual(payload["schema"]["status"], "missing") self.assertEqual(payload["page_count"], 0) + self.assertEqual(payload["search_backend"], "unavailable") self.assertEqual(payload["next_actions"][0]["tool"], "doctor") From 809fa0fa7d61f4a45419097c2d5451cd1f622de6 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:22:00 -0600 Subject: [PATCH 151/292] Add benchmark readiness verdict --- CHANGELOG.md | 1 + README.md | 2 +- link.py | 40 +++++++++++++++++++++++++++++++++++++++- tests/test_link_cli.py | 10 ++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a86cb..8e61e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link init` to create or repair a normal Link wiki without loading demo content. - Added `link serve` to start the local web viewer without remembering `serve.py` paths. - Added `link benchmark` to measure local cache, search, smart query, and graph timings on a user's current wiki. +- Added an interactive-readiness verdict and threshold warnings to `link benchmark` so larger local wikis are easier to evaluate. - Added an explicit local HTTP API version header and status field for future integration compatibility. - Added wiki schema markers with safe `link migrate`/MCP `migrate_wiki` migrations for future local format changes. - Added first-run agent prompts to installer output so new users can immediately try brief, remember, and query workflows. diff --git a/README.md b/README.md index 6a5c2e0..de4f780 100644 --- a/README.md +++ b/README.md @@ -561,7 +561,7 @@ repo-local or source checkout, use `python3 link.py ` in that directory | `link redact-capture ` | Replace secret-looking values in a saved raw capture and log labels/counts only. | | `link delete-capture --confirm` | Delete a saved raw capture after explicit confirmation. | | `link query "task" [--budget small\|medium\|large] [--project slug]` | Build a compact answer-ready packet from memory, wiki search, graph context, provenance, and estimated packet size. | -| `link benchmark ["query"] [--budget small\|medium\|large] [--project slug]` | Measure local cache, search, smart query, and graph timings for your current wiki. | +| `link benchmark ["query"] [--budget small\|medium\|large] [--project slug]` | Measure local cache, search, smart query, graph timings, search backend, and an interactive-readiness verdict for your current wiki. | | `link brief "task" [--project slug]` | Prime an agent with profile counts, relevant memories, review warnings, saved capture status, and safe memory rules. | | `link memory-audit [--project slug]` | Read-only health report for memory review backlog, raw captures, risk factors, and next actions. | | `link recall "query" [--project slug]` | Search local agent memories with recall readiness. | diff --git a/link.py b/link.py index f5f0c62..82ed4d6 100644 --- a/link.py +++ b/link.py @@ -43,7 +43,7 @@ import sys import time from pathlib import Path -from typing import Callable +from typing import Callable, Mapping ROOT = Path(__file__).resolve().parent @@ -173,6 +173,7 @@ from link_core.wiki import ( build_backlinks as _core_build_backlinks, build_wiki_cache as _core_build_wiki_cache, + close_wiki_cache as _core_close_wiki_cache, graph_data as _core_graph_data, rebuild_index as _core_rebuild_index, search_pages as _core_search_pages, @@ -2648,6 +2649,33 @@ def _timed(label: str, fn: Callable[[], object]) -> tuple[str, object, float]: return label, value, time.perf_counter() - start +BENCHMARK_THRESHOLDS_SECONDS = { + "cache": 5.0, + "search": 1.0, + "query": 3.0, + "graph": 2.0, +} + + +def _benchmark_health(payload: Mapping[str, object]) -> dict[str, object]: + timings = payload.get("timings") + if not isinstance(timings, Mapping): + timings = {} + warnings: list[str] = [] + for label, ceiling in BENCHMARK_THRESHOLDS_SECONDS.items(): + elapsed = timings.get(label) + if isinstance(elapsed, (int, float)) and elapsed > ceiling: + warnings.append(f"{label} took {elapsed:.4f}s, above the {ceiling:.1f}s interactive target") + if int(payload.get("pages") or 0) >= 1000 and payload.get("search_backend") != "sqlite-fts": + warnings.append("large wiki is using token-index fallback; SQLite FTS would improve search headroom") + return { + "status": "warn" if warnings else "pass", + "label": "review" if warnings else "interactive", + "thresholds_seconds": BENCHMARK_THRESHOLDS_SECONDS, + "warnings": warnings, + } + + def benchmark( target: Path, query_text: str = "agent memory", @@ -2700,6 +2728,8 @@ def benchmark( "timings": {key: round(value, 4) for key, value in timings.items()}, "budget_report": budget_report, } + payload["health"] = _benchmark_health(payload) + _core_close_wiki_cache(cache) if json_output: print(json.dumps(payload, indent=2)) return 0 @@ -2712,10 +2742,18 @@ def benchmark( print(f"Scale: {payload['pages']} pages · {payload['memories']} memories · {payload['edges']} edges") print(f"Search backend: {payload['search_backend']}") print(f"Results: {payload['search_results']} search results · {payload['context_items']} context items") + health = payload["health"] + if isinstance(health, Mapping): + print(f"Verdict: {health.get('label', 'unknown')}") print("") print("Timings") for key in ("cache", "search", "query", "graph"): print(f"- {key}: {payload['timings'][key]:.4f}s") + if isinstance(health, Mapping) and health.get("warnings"): + print("") + print("Warnings") + for warning in health["warnings"]: + print(f"- {warning}") if isinstance(budget_report, dict): packet_report = budget_report.get("context_packet") if isinstance(packet_report, dict): diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 2198358..5c47056 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -791,6 +791,16 @@ def test_benchmark_reports_local_query_timings(self): self.assertIn("query", payload["timings"]) self.assertIn("graph", payload["timings"]) self.assertGreater(payload["budget_report"]["context_packet"]["estimated_chars"], 0) + self.assertEqual(payload["health"]["status"], "pass") + self.assertEqual(payload["health"]["label"], "interactive") + self.assertIn("search", payload["health"]["thresholds_seconds"]) + + text_out = StringIO() + with redirect_stdout(text_out): + text_code = link_cli.benchmark(target, "agent memory", budget="small") + + self.assertEqual(text_code, 0) + self.assertIn("Verdict: interactive", text_out.getvalue()) def test_brief_surfaces_saved_captures_without_secret_values(self): tmp = Path(tempfile.mkdtemp(prefix="link-memory-test-")) From ee52ba9f8135283fa8187c96a6ef1302f54f08d2 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:23:53 -0600 Subject: [PATCH 152/292] Surface post-ingest checks --- CHANGELOG.md | 1 + link.py | 5 +++++ serve.py | 11 ++++++++++- tests/test_link_cli.py | 2 ++ tests/test_serve.py | 2 ++ 5 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e61e81..8e40476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added wiki schema markers with safe `link migrate`/MCP `migrate_wiki` migrations for future local format changes. - Added first-run agent prompts to installer output so new users can immediately try brief, remember, and query workflows. - Added guided `link ingest-status` output with structured JSON guidance, exact agent prompts, and follow-up validation commands. +- Added visible post-ingest checks to the CLI and local ingest UI so users see the rebuild/validate/status loop before relying on generated pages. - Added `/ingest` and `/api/ingest-status` so the local UI shows pending raw files, graph health, and the next agent prompt. - Added MCP `ingest_status` so MCP-only agents can inspect pending raw files and validation guidance. - Added `link rebuild-index`, MCP `rebuild_index`, and `POST /api/rebuild-index` to regenerate the human-readable wiki catalog from current pages. diff --git a/link.py b/link.py index 82ed4d6..0a879c4 100644 --- a/link.py +++ b/link.py @@ -1618,6 +1618,7 @@ def ingest_status(target: Path, json_output: bool = False) -> int: plan = status.get("plan") if isinstance(status.get("plan"), dict) else {} steps = plan.get("steps") if isinstance(plan.get("steps"), list) else [] batch = plan.get("batch") if isinstance(plan.get("batch"), list) else [] + post_checks = plan.get("post_checks") if isinstance(plan.get("post_checks"), list) else [] if plan: print("") print(f"Suggested workflow: {plan.get('title')}") @@ -1633,6 +1634,10 @@ def ingest_status(target: Path, json_output: bool = False) -> int: print(" Batch:") for item in batch[:5]: print(f" - {item['raw']} -> {item['suggested_source_page']}") + if post_checks: + print(" Post-ingest checks:") + for check in post_checks[:6]: + print(f" - {check}") return 0 diff --git a/serve.py b/serve.py index cfa1a87..7d3f00a 100644 --- a/serve.py +++ b/serve.py @@ -2330,6 +2330,7 @@ def _render_ingest(): if plan: steps = plan.get("steps") if isinstance(plan.get("steps"), list) else [] batch = plan.get("batch") if isinstance(plan.get("batch"), list) else [] + post_checks = plan.get("post_checks") if isinstance(plan.get("post_checks"), list) else [] step_html = "".join(f"
  • {html.escape(str(step))}
  • " for step in steps[:6]) batch_html = "" if batch: @@ -2340,10 +2341,18 @@ def _render_ingest(): f'{html.escape(str(item.get("suggested_source_page") or ""))}' ) batch_html = f'

    Batch

      {rows}
    ' + checks_html = "" + if post_checks: + rows = "".join( + f'
  • {html.escape(str(check))}' + f'run before reporting done
  • ' + for check in post_checks[:6] + ) + checks_html = f'

    Post-ingest checks

      {rows}
    ' plan_html = ( f'

    {html.escape(str(plan.get("title") or "Suggested Workflow"))}

    ' f'

    {html.escape(str(plan.get("summary") or ""))}

    ' - f'
      {step_html}
    {batch_html}
    ' + f'
      {step_html}
    {batch_html}{checks_html}' ) body = ( diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 5c47056..03baa97 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -229,6 +229,8 @@ def test_ingest_status_reports_pending_raw_file(self): self.assertIn("Suggested workflow: Ingest pending raw sources", out.getvalue()) self.assertIn("Memory review: propose memories from raw/new-source.md", out.getvalue()) self.assertIn("raw/new-source.md -> wiki/sources/new-source.md", out.getvalue()) + self.assertIn("Post-ingest checks:", out.getvalue()) + self.assertIn("link status --validate", out.getvalue()) def test_ingest_status_json(self): tmp = Path(tempfile.mkdtemp(prefix="link-ingest-test-")) diff --git a/tests/test_serve.py b/tests/test_serve.py index 69dad02..58f832a 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -1035,6 +1035,8 @@ def test_ingest_page_and_api_show_pending_raw(self): self.assertIn("Ingest path", html) self.assertIn("Optional memory", html) self.assertIn("propose memories from raw/new-source.md", html) + self.assertIn("Post-ingest checks", html) + self.assertIn("run before reporting done", html) self.assertIn("Ingest pending raw sources", html) self.assertIn("wiki/sources/new-source.md", html) self.assertIn('/propose?source=raw/new-source.md', html) From 50170b7d167d3460fe6d92544c2dc0fc2485bf17 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:27:04 -0600 Subject: [PATCH 153/292] Share benchmark health checks --- CHANGELOG.md | 1 + link.py | 32 +++--------------- mcp_package/link_core/benchmark.py | 32 ++++++++++++++++++ scripts/smoke_large_wiki.py | 9 +++-- tests/test_benchmark_core.py | 53 ++++++++++++++++++++++++++++++ tests/test_large_wiki_smoke.py | 12 ++++++- 6 files changed, 108 insertions(+), 31 deletions(-) create mode 100644 mcp_package/link_core/benchmark.py create mode 100644 tests/test_benchmark_core.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e40476..1962cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link serve` to start the local web viewer without remembering `serve.py` paths. - Added `link benchmark` to measure local cache, search, smart query, and graph timings on a user's current wiki. - Added an interactive-readiness verdict and threshold warnings to `link benchmark` so larger local wikis are easier to evaluate. +- Added shared benchmark health checks to the large-wiki smoke so user-facing and CI scale verdicts stay aligned. - Added an explicit local HTTP API version header and status field for future integration compatibility. - Added wiki schema markers with safe `link migrate`/MCP `migrate_wiki` migrations for future local format changes. - Added first-run agent prompts to installer output so new users can immediately try brief, remember, and query workflows. diff --git a/link.py b/link.py index 0a879c4..63c1de0 100644 --- a/link.py +++ b/link.py @@ -131,6 +131,9 @@ create_backup as _core_create_backup, list_backups as _core_list_backups, ) +from link_core.benchmark import ( + benchmark_health as _core_benchmark_health, +) from link_core.capture import ( capture_filename as _core_capture_filename, capture_inbox as _core_capture_inbox, @@ -2654,33 +2657,6 @@ def _timed(label: str, fn: Callable[[], object]) -> tuple[str, object, float]: return label, value, time.perf_counter() - start -BENCHMARK_THRESHOLDS_SECONDS = { - "cache": 5.0, - "search": 1.0, - "query": 3.0, - "graph": 2.0, -} - - -def _benchmark_health(payload: Mapping[str, object]) -> dict[str, object]: - timings = payload.get("timings") - if not isinstance(timings, Mapping): - timings = {} - warnings: list[str] = [] - for label, ceiling in BENCHMARK_THRESHOLDS_SECONDS.items(): - elapsed = timings.get(label) - if isinstance(elapsed, (int, float)) and elapsed > ceiling: - warnings.append(f"{label} took {elapsed:.4f}s, above the {ceiling:.1f}s interactive target") - if int(payload.get("pages") or 0) >= 1000 and payload.get("search_backend") != "sqlite-fts": - warnings.append("large wiki is using token-index fallback; SQLite FTS would improve search headroom") - return { - "status": "warn" if warnings else "pass", - "label": "review" if warnings else "interactive", - "thresholds_seconds": BENCHMARK_THRESHOLDS_SECONDS, - "warnings": warnings, - } - - def benchmark( target: Path, query_text: str = "agent memory", @@ -2733,7 +2709,7 @@ def benchmark( "timings": {key: round(value, 4) for key, value in timings.items()}, "budget_report": budget_report, } - payload["health"] = _benchmark_health(payload) + payload["health"] = _core_benchmark_health(payload) _core_close_wiki_cache(cache) if json_output: print(json.dumps(payload, indent=2)) diff --git a/mcp_package/link_core/benchmark.py b/mcp_package/link_core/benchmark.py new file mode 100644 index 0000000..0fb943d --- /dev/null +++ b/mcp_package/link_core/benchmark.py @@ -0,0 +1,32 @@ +"""Shared benchmark health helpers for Link.""" +from __future__ import annotations + +from typing import Mapping + + +BENCHMARK_THRESHOLDS_SECONDS = { + "cache": 5.0, + "search": 1.0, + "query": 3.0, + "graph": 2.0, +} + + +def benchmark_health(payload: Mapping[str, object]) -> dict[str, object]: + """Return a compact interactive-readiness verdict for benchmark output.""" + timings = payload.get("timings") + if not isinstance(timings, Mapping): + timings = {} + warnings: list[str] = [] + for label, ceiling in BENCHMARK_THRESHOLDS_SECONDS.items(): + elapsed = timings.get(label) + if isinstance(elapsed, (int, float)) and elapsed > ceiling: + warnings.append(f"{label} took {elapsed:.4f}s, above the {ceiling:.1f}s interactive target") + if int(payload.get("pages") or 0) >= 1000 and payload.get("search_backend") != "sqlite-fts": + warnings.append("large wiki is using token-index fallback; SQLite FTS would improve search headroom") + return { + "status": "warn" if warnings else "pass", + "label": "review" if warnings else "interactive", + "thresholds_seconds": BENCHMARK_THRESHOLDS_SECONDS, + "warnings": warnings, + } diff --git a/scripts/smoke_large_wiki.py b/scripts/smoke_large_wiki.py index 98b48ff..e5f1242 100644 --- a/scripts/smoke_large_wiki.py +++ b/scripts/smoke_large_wiki.py @@ -13,9 +13,10 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "mcp_package")) +from link_core.benchmark import benchmark_health # noqa: E402 from link_core.memory import memory_records # noqa: E402 from link_core.query import query_link # noqa: E402 -from link_core.wiki import build_backlinks, build_wiki_cache, graph_data, search_pages # noqa: E402 +from link_core.wiki import build_backlinks, build_wiki_cache, close_wiki_cache, graph_data, search_pages # noqa: E402 DEFAULT_MAX_SECONDS = { "cache": 5.0, @@ -162,7 +163,7 @@ def run_smoke(work_dir: Path, page_count: int, max_seconds: dict[str, float] | N max_seconds = max_seconds or DEFAULT_MAX_SECONDS check_timing_thresholds(timings, max_seconds) - return { + payload = { "wiki": str(wiki), "pages": len(cache["pages"]), "edges": len(graph["edges"]), @@ -172,6 +173,10 @@ def run_smoke(work_dir: Path, page_count: int, max_seconds: dict[str, float] | N "timings": {key: round(value, 4) for key, value in timings.items()}, "max_seconds": max_seconds, } + payload["health"] = benchmark_health(payload) + require(payload["health"]["status"] == "pass", "large-wiki benchmark health did not pass") + close_wiki_cache(cache) + return payload def main() -> int: diff --git a/tests/test_benchmark_core.py b/tests/test_benchmark_core.py new file mode 100644 index 0000000..43bd8d5 --- /dev/null +++ b/tests/test_benchmark_core.py @@ -0,0 +1,53 @@ +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "mcp_package")) + +from link_core.benchmark import benchmark_health # noqa: E402 + + +class BenchmarkCoreTests(unittest.TestCase): + def test_benchmark_health_passes_fast_sqlite_search(self): + payload = { + "pages": 1200, + "search_backend": "sqlite-fts", + "timings": {"cache": 0.2, "search": 0.01, "query": 0.03, "graph": 0.04}, + } + + health = benchmark_health(payload) + + self.assertEqual(health["status"], "pass") + self.assertEqual(health["label"], "interactive") + self.assertEqual(health["warnings"], []) + + def test_benchmark_health_warns_on_slow_paths(self): + payload = { + "pages": 20, + "search_backend": "sqlite-fts", + "timings": {"cache": 0.2, "search": 1.5, "query": 0.03, "graph": 0.04}, + } + + health = benchmark_health(payload) + + self.assertEqual(health["status"], "warn") + self.assertEqual(health["label"], "review") + self.assertIn("search took 1.5000s", health["warnings"][0]) + + def test_benchmark_health_warns_on_large_token_fallback(self): + payload = { + "pages": 1000, + "search_backend": "token-index", + "timings": {"cache": 0.2, "search": 0.01, "query": 0.03, "graph": 0.04}, + } + + health = benchmark_health(payload) + + self.assertEqual(health["status"], "warn") + self.assertIn("SQLite FTS", health["warnings"][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_large_wiki_smoke.py b/tests/test_large_wiki_smoke.py index 879deaa..066c230 100644 --- a/tests/test_large_wiki_smoke.py +++ b/tests/test_large_wiki_smoke.py @@ -11,7 +11,7 @@ from link_core.memory import memory_records # noqa: E402 from link_core.query import query_link # noqa: E402 -from link_core.wiki import build_backlinks, build_wiki_cache, graph_data # noqa: E402 +from link_core.wiki import build_backlinks, build_wiki_cache, close_wiki_cache, graph_data # noqa: E402 SPEC = importlib.util.spec_from_file_location( "smoke_large_wiki", ROOT / "scripts/smoke_large_wiki.py" @@ -115,6 +115,7 @@ def test_smart_query_and_graph_handle_hundreds_of_pages(self): self.assertEqual(packet["follow_up"][0]["tool"], "query_link") self.assertEqual(len(graph["nodes"]), page_count + 30) self.assertGreaterEqual(len(graph["edges"]), page_count) + close_wiki_cache(cache) def test_large_wiki_smoke_enforces_timing_thresholds(self): smoke_large_wiki.check_timing_thresholds({"query": 0.01}, {"query": 0.02}) @@ -122,6 +123,15 @@ def test_large_wiki_smoke_enforces_timing_thresholds(self): with self.assertRaisesRegex(smoke_large_wiki.SmokeFailure, "above 0.0200s threshold"): smoke_large_wiki.check_timing_thresholds({"query": 0.03}, {"query": 0.02}) + def test_large_wiki_smoke_reports_benchmark_health(self): + root = Path(tempfile.mkdtemp(prefix="link-large-wiki-health-")) + + payload = smoke_large_wiki.run_smoke(root, 80) + + self.assertEqual(payload["health"]["status"], "pass") + self.assertEqual(payload["health"]["label"], "interactive") + self.assertIn("thresholds_seconds", payload["health"]) + if __name__ == "__main__": unittest.main() From ef6187a9c68a2f9c10705cb026260ecc6d1ddf7b Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:30:19 -0600 Subject: [PATCH 154/292] Close short-lived search caches --- CHANGELOG.md | 1 + link.py | 22 +++-- mcp_package/link_core/wiki.py | 154 ++++++++++++++++++---------------- tests/test_wiki_core.py | 61 ++++++++++++++ 4 files changed, 157 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1962cda..9e2eeb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link benchmark` to measure local cache, search, smart query, and graph timings on a user's current wiki. - Added an interactive-readiness verdict and threshold warnings to `link benchmark` so larger local wikis are easier to evaluate. - Added shared benchmark health checks to the large-wiki smoke so user-facing and CI scale verdicts stay aligned. +- Tightened ownership of generated search caches in CLI query and index rebuild paths so in-memory SQLite indexes are closed when short-lived operations finish. - Added an explicit local HTTP API version header and status field for future integration compatibility. - Added wiki schema markers with safe `link migrate`/MCP `migrate_wiki` migrations for future local format changes. - Added first-run agent prompts to installer output so new users can immediately try brief, remember, and query workflows. diff --git a/link.py b/link.py index 63c1de0..c2b339a 100644 --- a/link.py +++ b/link.py @@ -888,15 +888,19 @@ def _memory_brief(wiki_dir: Path, query: str = "", limit: int = 6, project: str def _query_link(wiki_dir: Path, query: str, budget: str = "medium", project: str | None = None) -> dict[str, object]: - return _core_query_link( - wiki_dir, - query, - _core_build_wiki_cache(wiki_dir), - _memory_records(wiki_dir), - budget=budget, - project=project, - review_command="review-memory", - ) + cache = _core_build_wiki_cache(wiki_dir) + try: + return _core_query_link( + wiki_dir, + query, + cache, + _memory_records(wiki_dir), + budget=budget, + project=project, + review_command="review-memory", + ) + finally: + _core_close_wiki_cache(cache) def _recall_memories( diff --git a/mcp_package/link_core/wiki.py b/mcp_package/link_core/wiki.py index b866f18..291ed51 100644 --- a/mcp_package/link_core/wiki.py +++ b/mcp_package/link_core/wiki.py @@ -392,50 +392,55 @@ def build_index_markdown( generated_at: str | None = None, ) -> str: """Build a deterministic, human-readable catalog for a Link wiki.""" + owns_cache = cache is None cache = cache or build_wiki_cache(wiki_dir) - pages = sorted(_index_pages(cache), key=_page_sort_key) - generated_at = generated_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - source_count = sum( - 1 for page in pages - if str(page.get("category") or "") == "sources" or str(page.get("type") or "") == "source" - ) - memory_count = sum( - 1 for page in pages - if str(page.get("category") or "") == "memories" or str(page.get("type") or "") == "memory" - ) - - categories: dict[str, list[dict[str, Any]]] = {} - for page in pages: - categories.setdefault(str(page.get("category") or "root"), []).append(page) - - lines = [ - "# Link Wiki Index", - "", - f"> Last updated: {generated_at} | {len(pages)} pages | {source_count} sources | {memory_count} memories", - "", - "## Categories", - "", - ] - for category in sorted(categories, key=_category_sort_key): - title = INDEX_CATEGORY_TITLES.get(category, category.replace("-", " ").title()) - lines.append(f"- {title}: {len(categories[category])}") - if not categories: - lines.append("- No pages yet") - - for category in sorted(categories, key=_category_sort_key): - title = INDEX_CATEGORY_TITLES.get(category, category.replace("-", " ").title()) - lines.extend(["", f"### {category}", ""]) - for page in categories[category]: - lines.append(_index_entry(page, cache)) - - lines.extend([ - "", - "## Recent", - "", - "See [[log]] for the append-only local audit trail.", - "", - ]) - return "\n".join(lines) + try: + pages = sorted(_index_pages(cache), key=_page_sort_key) + generated_at = generated_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + source_count = sum( + 1 for page in pages + if str(page.get("category") or "") == "sources" or str(page.get("type") or "") == "source" + ) + memory_count = sum( + 1 for page in pages + if str(page.get("category") or "") == "memories" or str(page.get("type") or "") == "memory" + ) + + categories: dict[str, list[dict[str, Any]]] = {} + for page in pages: + categories.setdefault(str(page.get("category") or "root"), []).append(page) + + lines = [ + "# Link Wiki Index", + "", + f"> Last updated: {generated_at} | {len(pages)} pages | {source_count} sources | {memory_count} memories", + "", + "## Categories", + "", + ] + for category in sorted(categories, key=_category_sort_key): + title = INDEX_CATEGORY_TITLES.get(category, category.replace("-", " ").title()) + lines.append(f"- {title}: {len(categories[category])}") + if not categories: + lines.append("- No pages yet") + + for category in sorted(categories, key=_category_sort_key): + title = INDEX_CATEGORY_TITLES.get(category, category.replace("-", " ").title()) + lines.extend(["", f"### {category}", ""]) + for page in categories[category]: + lines.append(_index_entry(page, cache)) + + lines.extend([ + "", + "## Recent", + "", + "See [[log]] for the append-only local audit trail.", + "", + ]) + return "\n".join(lines) + finally: + if owns_cache: + close_wiki_cache(cache) def rebuild_index( @@ -444,33 +449,38 @@ def rebuild_index( generated_at: str | None = None, ) -> dict[str, Any]: """Regenerate wiki/index.md from the current Markdown pages.""" + owns_cache = cache is None cache = cache or build_wiki_cache(wiki_dir) - markdown = build_index_markdown(wiki_dir, cache=cache, generated_at=generated_at) - index_path = wiki_dir / "index.md" - index_path.write_text(markdown, encoding="utf-8") - pages = _index_pages(cache) - category_counts: dict[str, int] = {} - for page in pages: - category = str(page.get("category") or "root") - category_counts[category] = category_counts.get(category, 0) + 1 - return { - "rebuilt": True, - "path": "wiki/index.md", - "page_count": len(pages), - "source_count": sum( - 1 for page in pages - if str(page.get("category") or "") == "sources" or str(page.get("type") or "") == "source" - ), - "memory_count": sum( - 1 for page in pages - if str(page.get("category") or "") == "memories" or str(page.get("type") or "") == "memory" - ), - "category_counts": dict(sorted(category_counts.items(), key=lambda item: _category_sort_key(item[0]))), - "next_actions": [ - { - "tool": "rebuild_backlinks", - "command": "link rebuild-backlinks", - "reason": "Regenerated index links change graph edges; rebuild backlinks before validation.", - } - ], - } + try: + markdown = build_index_markdown(wiki_dir, cache=cache, generated_at=generated_at) + index_path = wiki_dir / "index.md" + index_path.write_text(markdown, encoding="utf-8") + pages = _index_pages(cache) + category_counts: dict[str, int] = {} + for page in pages: + category = str(page.get("category") or "root") + category_counts[category] = category_counts.get(category, 0) + 1 + return { + "rebuilt": True, + "path": "wiki/index.md", + "page_count": len(pages), + "source_count": sum( + 1 for page in pages + if str(page.get("category") or "") == "sources" or str(page.get("type") or "") == "source" + ), + "memory_count": sum( + 1 for page in pages + if str(page.get("category") or "") == "memories" or str(page.get("type") or "") == "memory" + ), + "category_counts": dict(sorted(category_counts.items(), key=lambda item: _category_sort_key(item[0]))), + "next_actions": [ + { + "tool": "rebuild_backlinks", + "command": "link rebuild-backlinks", + "reason": "Regenerated index links change graph edges; rebuild backlinks before validation.", + } + ], + } + finally: + if owns_cache: + close_wiki_cache(cache) diff --git a/tests/test_wiki_core.py b/tests/test_wiki_core.py index 3d28a7e..c0251c3 100644 --- a/tests/test_wiki_core.py +++ b/tests/test_wiki_core.py @@ -5,6 +5,7 @@ import time import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] @@ -212,6 +213,66 @@ def test_rebuild_index_generates_category_catalog(self): self.assertEqual(result["category_counts"]["concepts"], 1) self.assertEqual(result["next_actions"][0]["tool"], "rebuild_backlinks") + def test_index_build_closes_owned_cache(self): + wiki = self.make_wiki() + + class FakeIndex: + closed = False + + def close(self): + self.closed = True + + fake = FakeIndex() + cache = { + "pages": [ + { + "name": "agent-memory", + "title": "Agent Memory", + "category": "concepts", + "type": "concept", + "tldr": "Durable memory.", + } + ], + "snippet_index": {}, + "fts_index": fake, + } + + with patch("link_core.wiki.build_wiki_cache", return_value=cache): + markdown = build_index_markdown(wiki) + + self.assertIn("[[agent-memory]]", markdown) + self.assertTrue(fake.closed) + + def test_rebuild_index_closes_owned_cache(self): + wiki = self.make_wiki() + + class FakeIndex: + closed = False + + def close(self): + self.closed = True + + fake = FakeIndex() + cache = { + "pages": [ + { + "name": "agent-memory", + "title": "Agent Memory", + "category": "concepts", + "type": "concept", + "tldr": "Durable memory.", + } + ], + "snippet_index": {}, + "fts_index": fake, + } + + with patch("link_core.wiki.build_wiki_cache", return_value=cache): + result = rebuild_index(wiki) + + self.assertEqual(result["page_count"], 1) + self.assertTrue(fake.closed) + if __name__ == "__main__": unittest.main() From 7ba37c1d2c06d42c9199bb8675b9d55153be4d58 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:33:34 -0600 Subject: [PATCH 155/292] Add proposal review gate --- CHANGELOG.md | 1 + serve.py | 29 ++++++++++++++++++++++++++++- tests/test_serve.py | 6 ++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2eeb5..5ed3fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added Explain Memory views with `explain-memory`, MCP `explain_memory`, `/explain-memory`, and `/api/explain-memory` for provenance, review state, lifecycle, graph links, and recall readiness. - Added `/propose`, a read-only local UI for turning pasted source/session notes into memory proposals without writing pages. - Added guarded web approval actions on `/propose` with local-only `remember-memory` and `update-memory` APIs for explicitly saving selected proposals. +- Added a visible review gate to `/propose`, including manual-review states for duplicate/conflict proposals before durable memory writes. - Added MCP `link_status` and `/api/status` for a compact readiness summary with version, wiki path, page/memory counts, optional validation, and safe next actions. - Added search backend reporting to Link status payloads so agents and users can see whether local search is using SQLite FTS or the token fallback. - Added `link.py status` so the same readiness summary is available before MCP or the local web server is connected. diff --git a/serve.py b/serve.py index 7d3f00a..d048712 100644 --- a/serve.py +++ b/serve.py @@ -1106,6 +1106,10 @@ def _flush_blockquote(): .proposal-results { display: grid; gap: 12px; margin-top: 14px; } .proposal-card { border: 1px solid var(--border-soft); border-radius: 6px; padding: 12px; background: var(--surface); min-width: 0; } .proposal-card h3 { margin-top: 0; font-size: 16px; } +.proposal-checklist { display: grid; gap: 5px; margin: 10px 0; padding: 9px 10px; + border: 1px solid var(--border-soft); border-radius: 6px; background: var(--surface-soft); + color: var(--muted); font-family: sans-serif; font-size: 13px; line-height: 1.4; } +.proposal-checklist strong { color: var(--text); } .proposal-warning { color: #8a6d3b; font-family: sans-serif; font-size: 13px; line-height: 1.45; } .proposal-command { display: block; margin-top: 10px; padding: 8px; background: var(--surface-code); border-radius: 4px; white-space: normal; overflow-wrap: anywhere; } @@ -1113,6 +1117,7 @@ def _flush_blockquote(): .proposal-actions button { border: 1px solid var(--border); background: var(--button-bg); color: var(--button-text); border-radius: 4px; padding: 5px 8px; cursor: pointer; font: inherit; } .proposal-actions button:hover { background: var(--button-hover); } +.proposal-actions button:disabled { color: var(--button-disabled); cursor: default; } .memory-issues { margin-top: 6px; } .memory-issues li { border: none; padding: 0; color: var(--muted); font-size: 13px; } .memory-issues .severity { font-family: sans-serif; font-size: 11px; text-transform: uppercase; color: #8a6d3b; } @@ -1497,10 +1502,19 @@ def _flush_blockquote(): function addApproveButton(parent, proposal) { var endpoint = approvalEndpoint(proposal); - if (!endpoint) return; + if (!endpoint) { + var blocked = document.createElement('button'); + blocked.type = 'button'; + blocked.disabled = true; + blocked.textContent = 'Manual review required'; + blocked.title = 'Copy the approval prompt and resolve duplicates or conflicts with your agent.'; + parent.appendChild(blocked); + return; + } var button = document.createElement('button'); button.type = 'button'; button.textContent = endpoint === '/api/update-memory' ? 'Approve update' : 'Approve and save'; + button.title = 'Writes durable local memory only after this explicit approval.'; button.addEventListener('click', async function() { var message = endpoint === '/api/update-memory' ? 'Update the existing memory with this proposal?' @@ -1557,6 +1571,13 @@ def _flush_blockquote(): var action = proposal.primary_action || {}; if (action.label) addText(card, 'p', 'summary', action.label + ': ' + (action.description || '')); addText(card, 'p', 'proposal-warning', 'Proposal-only: no durable memory has been written yet.'); + var checklist = document.createElement('div'); + checklist.className = 'proposal-checklist'; + addText(checklist, 'strong', '', 'Review gate'); + addText(checklist, 'span', '', 'Save only if this is a durable preference, decision, fact, or project context.'); + addText(checklist, 'span', '', 'Check scope, project, source label, duplicates, and conflicts before approval.'); + addText(checklist, 'span', '', conflicts ? 'Conflict found: use the approval prompt instead of direct save.' : 'Direct save still requires explicit approval.'); + card.appendChild(checklist); var promptText = approvalPrompt(proposal); var prompt = addText(card, 'code', 'proposal-command', promptText); prompt.setAttribute('title', 'Copy this into your agent chat if you approve the memory.'); @@ -2197,6 +2218,12 @@ def _render_propose(project: str | None = None, source: str | None = None): f'

    Paste source notes, session notes, or a raw excerpt. Link returns memory candidates without writing anything.

    ' f'
    Trust rule' f'

    Source-backed wiki knowledge and durable agent memory are separate. Save only preferences, decisions, or project facts you approve.

    ' + f'

    Review Gate

    ' + f'Before saving memory' + f'Keep ordinary facts in wiki pages; save only durable preferences, decisions, project context, or user facts.' + f'Check source label, scope, project, duplicate candidates, and conflict warnings.' + f'Use direct approval only when the proposal is clean; otherwise copy the approval prompt into your agent chat.' + f'
    ' f'{proposal_path}' f'

    Local Raw Sources

    captures
    ' f'
    ' diff --git a/tests/test_serve.py b/tests/test_serve.py index 58f832a..e3e955f 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -928,10 +928,16 @@ def test_propose_page_renders_read_only_workflow(self): self.assertIn('value="link"', html) self.assertIn("without writing anything", html) self.assertIn("Save only preferences", html) + self.assertIn("Review Gate", html) + self.assertIn("Before saving memory", html) + self.assertIn("ordinary facts in wiki pages", html) self.assertIn("Memory proposal path", html) self.assertIn("Approve explicitly", html) self.assertIn("This step never writes durable memory", html) self.assertIn("Proposal-only: no durable memory has been written yet.", html) + self.assertIn("Manual review required", html) + self.assertIn("Conflict found: use the approval prompt", html) + self.assertIn("Writes durable local memory only after this explicit approval.", html) self.assertIn("Approve and save", html) self.assertIn("/api/remember-memory", html) self.assertIn("/api/update-memory", html) From 0f3f33bbd98cd4349abbbd7aacf74078311484ee Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:37:16 -0600 Subject: [PATCH 156/292] Add starter prompts command --- CHANGELOG.md | 1 + README.md | 7 +++ integrations/_shared/instructions.sh | 2 + integrations/_shared/scaffold.sh | 4 ++ link.py | 84 ++++++++++++++++++++++++++++ scripts/check_tool_contract.py | 1 + tests/test_link_cli.py | 31 ++++++++++ 7 files changed, 130 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed3fe0..9977aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added an explicit local HTTP API version header and status field for future integration compatibility. - Added wiki schema markers with safe `link migrate`/MCP `migrate_wiki` migrations for future local format changes. - Added first-run agent prompts to installer output so new users can immediately try brief, remember, and query workflows. +- Added `link prompts` to print the first-run natural agent prompts and local readiness checks on demand. - Added guided `link ingest-status` output with structured JSON guidance, exact agent prompts, and follow-up validation commands. - Added visible post-ingest checks to the CLI and local ingest UI so users see the rebuild/validate/status loop before relying on generated pages. - Added `/ingest` and `/api/ingest-status` so the local UI shows pending raw files, graph health, and the next agent prompt. diff --git a/README.md b/README.md index de4f780..fd8cf4c 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,12 @@ remember that I prefer short release notes query Link for the release process ``` +Or print the starter prompt list any time: + +```bash +link prompts +``` + If a source might contain preferences or decisions, ask for proposals first: ```text @@ -550,6 +556,7 @@ repo-local or source checkout, use `python3 link.py ` in that directory |---------|-------------| | `link init [dir]` | Create or repair a normal Link wiki without demo content. | | `link serve [dir] [--port 3000]` | Start the local web viewer for a Link wiki. | +| `link prompts [dir] [--project slug]` | Print first-run natural agent prompts plus the local readiness/check commands. | | `link status [--validate]` | Show local readiness, page/memory counts, optional validation summary, and next actions. | | `link backup [--label name] [--include-raw]` | Create a timestamped local `.link-backups/` archive of `wiki/`; raw sources are excluded unless explicitly requested. | | `link ingest-status` | Show pending raw files, graph index status, the next agent prompt, guided plan, and follow-up checks. | diff --git a/integrations/_shared/instructions.sh b/integrations/_shared/instructions.sh index 2de6c2f..c6001cf 100644 --- a/integrations/_shared/instructions.sh +++ b/integrations/_shared/instructions.sh @@ -43,6 +43,7 @@ link_print_next_steps() { if [ "$mode" = "--project" ]; then echo " Drop sources into raw/." echo " View wiki: python3 link.py serve" + echo " Print starter prompts: python3 link.py prompts" echo " Try in your agent:" echo " is Link ready?" echo " brief me from Link before we continue" @@ -52,6 +53,7 @@ link_print_next_steps() { else echo " Drop sources into ~/link/raw/." echo " View wiki: link serve" + echo " Print starter prompts: link prompts" echo " Try in your agent:" echo " is Link ready?" echo " brief me from Link before we continue" diff --git a/integrations/_shared/scaffold.sh b/integrations/_shared/scaffold.sh index 8ad372d..8d3a49a 100755 --- a/integrations/_shared/scaffold.sh +++ b/integrations/_shared/scaffold.sh @@ -216,6 +216,8 @@ if [ -f "$TARGET_DIR/link.py" ]; then if [ "$MODE" = "--project" ]; then echo " Check Link readiness:" echo " python3 link.py status --validate" + echo " Print starter prompts:" + echo " python3 link.py prompts" echo " Check wiki health:" echo " python3 link.py doctor" echo " Create a local backup:" @@ -229,6 +231,8 @@ if [ -f "$TARGET_DIR/link.py" ]; then else echo " Check Link readiness:" echo " link status --validate" + echo " Print starter prompts:" + echo " link prompts" echo " Check wiki health:" echo " link doctor" echo " Create a local backup:" diff --git a/link.py b/link.py index c2b339a..675a2f8 100644 --- a/link.py +++ b/link.py @@ -5,6 +5,7 @@ python link.py init [target] python link.py serve [target] python link.py demo [target] + python link.py prompts [target] python link.py status [target] python link.py backup [target] python link.py doctor [target] @@ -3119,6 +3120,82 @@ def init_wiki(target: Path) -> int: return 0 +def starter_prompts(target: Path, project: str | None = None, json_output: bool = False) -> int: + target = target.expanduser().resolve() + project_name = project if project is not None else _default_project(target) + remember_prompt = ( + "remember that this project uses Link for local agent memory" + if project_name + else "remember that I prefer local-first agent memory" + ) + query_prompt = ( + "query Link for what this project remembers" + if project_name + else "query Link for what you know about me" + ) + prompts = [ + { + "label": "Check readiness", + "prompt": "is Link ready?", + "when": "right after install or before troubleshooting", + }, + { + "label": "Prime memory", + "prompt": "brief me from Link before we continue", + "when": "at the start of a session or task", + }, + { + "label": "Save explicit memory", + "prompt": remember_prompt, + "when": "when you want future agents to remember a preference, decision, or project fact", + }, + { + "label": "Ask with context", + "prompt": query_prompt, + "when": "when you want a compact answer-ready packet from memory and wiki context", + }, + { + "label": "Ingest a source", + "prompt": "ingest raw/ into Link", + "when": "after dropping a source file into raw/", + }, + { + "label": "Review memory proposals", + "prompt": "propose memories from raw/", + "when": "when a source may contain preferences, decisions, or project context", + }, + ] + commands = [ + "link status --validate", + "link ingest-status", + "link memory-inbox", + "link benchmark \"agent memory\"", + ] + payload = { + "target": str(target), + "project": project_name, + "prompts": prompts, + "commands": commands, + } + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + + print(f"Link starter prompts: {target}") + if project_name: + print(f"Project: {project_name}") + print("") + print("Ask your agent") + for item in prompts: + print(f"- {item['prompt']}") + print(f" When: {item['when']}") + print("") + print("Local checks") + for command in commands: + print(f"- {command}") + return 0 + + def serve_wiki(target: Path, port: int = 3000) -> int: target = target.expanduser().resolve() serve_path = target / "serve.py" @@ -3206,6 +3283,11 @@ def main(argv: list[str] | None = None) -> int: demo.add_argument("target", nargs="?", default=DEFAULT_DEMO_DIR) demo.add_argument("--force", action="store_true", help="replace an existing Link demo directory") + prompts_cmd = sub.add_parser("prompts", help="print first-run agent prompts and local checks") + prompts_cmd.add_argument("target", nargs="?", default=".") + prompts_cmd.add_argument("--project", default=None, help="project slug for project-scoped prompt examples") + prompts_cmd.add_argument("--json", action="store_true", help="print machine-readable prompt data") + status_cmd = sub.add_parser("status", help="show Link readiness, counts, and next actions") status_cmd.add_argument("target", nargs="?", default=".") status_cmd.add_argument("--validate", action="store_true", help="include the ingest validation gate summary") @@ -3398,6 +3480,8 @@ def main(argv: list[str] | None = None) -> int: if args.command == "demo": create_demo(Path(args.target), force=args.force) return 0 + if args.command == "prompts": + return starter_prompts(Path(args.target), project=args.project, json_output=args.json) if args.command == "status": return status(Path(args.target), include_validation=args.validate, json_output=args.json) if args.command == "backup": diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 6dab0f0..c22efd7 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -27,6 +27,7 @@ "memory-inbox", "migrate", "profile", + "prompts", "propose-memories", "query", "query-link", diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 03baa97..3fac5ec 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -79,6 +79,37 @@ def test_init_copies_core_from_installed_runtime_layout(self): self.assertEqual(code, 0) self.assertTrue((target / "link_core/frontmatter.py").exists()) + def test_prompts_prints_first_run_agent_prompts(self): + tmp = Path(tempfile.mkdtemp(prefix="link-prompts-test-")) + target = tmp / "my-link" + + out = StringIO() + with redirect_stdout(out): + code = link_cli.starter_prompts(target) + + self.assertEqual(code, 0) + self.assertIn("Link starter prompts:", out.getvalue()) + self.assertIn("is Link ready?", out.getvalue()) + self.assertIn("brief me from Link before we continue", out.getvalue()) + self.assertIn("remember that I prefer local-first agent memory", out.getvalue()) + self.assertIn("query Link for what you know about me", out.getvalue()) + self.assertIn("propose memories from raw/", out.getvalue()) + self.assertIn("link status --validate", out.getvalue()) + + def test_prompts_json_supports_project_examples(self): + tmp = Path(tempfile.mkdtemp(prefix="link-prompts-test-")) + target = tmp / "my-link" + + out = StringIO() + with redirect_stdout(out): + code = link_cli.starter_prompts(target, project="link", json_output=True) + payload = json.loads(out.getvalue()) + + self.assertEqual(code, 0) + self.assertEqual(payload["project"], "link") + self.assertIn("this project uses Link", payload["prompts"][2]["prompt"]) + self.assertIn("what this project remembers", payload["prompts"][3]["prompt"]) + def test_serve_runs_target_viewer(self): tmp = Path(tempfile.mkdtemp(prefix="link-serve-test-")) target = tmp / "demo" From 82f5c33dcef8d4e1742485843d1ef9199b9f6575 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:38:46 -0600 Subject: [PATCH 157/292] Cover starter prompts in first-use smoke --- CHANGELOG.md | 1 + scripts/smoke_first_use.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9977aa1..23e2f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added an explicit `system`/`dark`/`light` theme toggle for the local web UI; dark mode now uses a black page background. - Added a real MCP stdio smoke test for the built `link-mcp` wheel in CI. - Added a first-use smoke test for init, demo, status, query, brief, remember, capture, ingest-status, and validation workflows. +- Added `link prompts` coverage to the first-use smoke so CI validates the first-run agent prompt path. - Added large-wiki smoke coverage for smart query budgets and graph generation across hundreds of pages. - Added timing thresholds to large-wiki smoke coverage so major search/query/graph performance regressions fail early. - Added release hygiene checks that protect the public agent instruction contract for `query_link`, `validate_wiki`, and `memory_brief`. diff --git a/scripts/smoke_first_use.py b/scripts/smoke_first_use.py index dfae0e4..ab515a0 100644 --- a/scripts/smoke_first_use.py +++ b/scripts/smoke_first_use.py @@ -69,6 +69,17 @@ def run_smoke(work_dir: Path, python: str = sys.executable) -> None: require(init_status.get("ready") is True, "initialized wiki did not report ready") require(init_status.get("schema", {}).get("status") == "current", "initialized wiki schema is not current") + init_prompts = run_json("prompts", str(init_target), "--json", python=python) + require(len(init_prompts.get("prompts", [])) >= 6, "prompts did not return the first-run prompt set") + require( + init_prompts.get("prompts", [{}])[0].get("prompt") == "is Link ready?", + "prompts did not start with readiness guidance", + ) + require( + "link status --validate" in init_prompts.get("commands", []), + "prompts did not include readiness command", + ) + demo_result = run_link("demo", str(demo_target), "--force", python=python) require("Try the value loop:" in demo_result.stdout, "demo output did not show the value loop") require("query \"why does Link help agents?\"" in demo_result.stdout, "demo output did not show the query proof command") @@ -83,6 +94,12 @@ def run_smoke(work_dir: Path, python: str = sys.executable) -> None: require(demo_status.get("validation", {}).get("passed") is True, "demo validation did not pass") require(int(demo_status.get("memory_count") or 0) >= 1, "demo did not include a starter memory") + project_prompts = run_json("prompts", str(demo_target), "--project", "demo", "--json", python=python) + require( + "this project uses Link" in project_prompts.get("prompts", [{}, {}, {}])[2].get("prompt", ""), + "project prompts did not include project memory guidance", + ) + backup = run_json("backup", str(demo_target), "--label", "first-use-smoke", "--json", python=python) require(backup.get("created") is True, "backup did not create an archive") require(backup.get("included") == ["wiki"], "backup did not default to wiki-only") From 51d2971b65d08c5b933f6077b82b05b702186e0a Mon Sep 17 00:00:00 2001 From: Gowtham Date: Wed, 6 May 2026 22:42:18 -0600 Subject: [PATCH 158/292] Expose starter prompts in web UI --- CHANGELOG.md | 1 + README.md | 1 + link.py | 70 ++++---------------------------- mcp_package/link_core/prompts.py | 65 +++++++++++++++++++++++++++++ serve.py | 44 ++++++++++++++++++++ tests/test_serve.py | 17 ++++++++ 6 files changed, 137 insertions(+), 61 deletions(-) create mode 100644 mcp_package/link_core/prompts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e2f4e..2e09c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added wiki schema markers with safe `link migrate`/MCP `migrate_wiki` migrations for future local format changes. - Added first-run agent prompts to installer output so new users can immediately try brief, remember, and query workflows. - Added `link prompts` to print the first-run natural agent prompts and local readiness checks on demand. +- Added `/prompts` and `/api/prompts` so browser-first users get the same starter prompt guidance as the CLI. - Added guided `link ingest-status` output with structured JSON guidance, exact agent prompts, and follow-up validation commands. - Added visible post-ingest checks to the CLI and local ingest UI so users see the rebuild/validate/status loop before relying on generated pages. - Added `/ingest` and `/api/ingest-status` so the local UI shows pending raw files, graph health, and the next agent prompt. diff --git a/README.md b/README.md index fd8cf4c..17b5ef5 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,7 @@ Common endpoints: | Endpoint | Description | |----------|-------------| | `GET /api/status?validate=true` | Readiness summary with page/memory counts, optional validation summary, and safe next actions. | +| `GET /api/prompts?project=slug` | First-run natural agent prompts plus local readiness/check commands; same payload as `link prompts --json`. | | `GET /api/ingest-status` | Raw ingest state with pending files, graph health, exact agent prompt, guided plan, and follow-up commands. | | `GET /api/pages` | All pages with title, type, tags, aliases, maturity, and TLDR. | | `GET /api/memory-dashboard?project=` | Read-only memory dashboard data, including saved raw captures and secret-warning counts. | diff --git a/link.py b/link.py index 675a2f8..547f42f 100644 --- a/link.py +++ b/link.py @@ -168,6 +168,9 @@ from link_core.query import ( query_link as _core_query_link, ) +from link_core.prompts import ( + starter_prompt_payload as _core_starter_prompt_payload, +) from link_core.validation import ( validate_wiki as _core_validate_wiki, ) @@ -3121,77 +3124,22 @@ def init_wiki(target: Path) -> int: def starter_prompts(target: Path, project: str | None = None, json_output: bool = False) -> int: - target = target.expanduser().resolve() - project_name = project if project is not None else _default_project(target) - remember_prompt = ( - "remember that this project uses Link for local agent memory" - if project_name - else "remember that I prefer local-first agent memory" - ) - query_prompt = ( - "query Link for what this project remembers" - if project_name - else "query Link for what you know about me" - ) - prompts = [ - { - "label": "Check readiness", - "prompt": "is Link ready?", - "when": "right after install or before troubleshooting", - }, - { - "label": "Prime memory", - "prompt": "brief me from Link before we continue", - "when": "at the start of a session or task", - }, - { - "label": "Save explicit memory", - "prompt": remember_prompt, - "when": "when you want future agents to remember a preference, decision, or project fact", - }, - { - "label": "Ask with context", - "prompt": query_prompt, - "when": "when you want a compact answer-ready packet from memory and wiki context", - }, - { - "label": "Ingest a source", - "prompt": "ingest raw/ into Link", - "when": "after dropping a source file into raw/", - }, - { - "label": "Review memory proposals", - "prompt": "propose memories from raw/", - "when": "when a source may contain preferences, decisions, or project context", - }, - ] - commands = [ - "link status --validate", - "link ingest-status", - "link memory-inbox", - "link benchmark \"agent memory\"", - ] - payload = { - "target": str(target), - "project": project_name, - "prompts": prompts, - "commands": commands, - } + payload = _core_starter_prompt_payload(target, project=project) if json_output: print(json.dumps(payload, indent=2)) return 0 - print(f"Link starter prompts: {target}") - if project_name: - print(f"Project: {project_name}") + print(f"Link starter prompts: {payload['target']}") + if payload["project"]: + print(f"Project: {payload['project']}") print("") print("Ask your agent") - for item in prompts: + for item in payload["prompts"]: print(f"- {item['prompt']}") print(f" When: {item['when']}") print("") print("Local checks") - for command in commands: + for command in payload["commands"]: print(f"- {command}") return 0 diff --git a/mcp_package/link_core/prompts.py b/mcp_package/link_core/prompts.py new file mode 100644 index 0000000..830ecf7 --- /dev/null +++ b/mcp_package/link_core/prompts.py @@ -0,0 +1,65 @@ +"""Shared first-run prompt helpers for Link.""" +from __future__ import annotations + +from pathlib import Path + +from .memory import default_project_for_target + + +def starter_prompt_payload(target: Path, project: str | None = None) -> dict[str, object]: + """Return natural agent prompts and local checks for a Link user.""" + target = target.expanduser().resolve() + project_name = project if project is not None else default_project_for_target(target) + remember_prompt = ( + "remember that this project uses Link for local agent memory" + if project_name + else "remember that I prefer local-first agent memory" + ) + query_prompt = ( + "query Link for what this project remembers" + if project_name + else "query Link for what you know about me" + ) + prompts = [ + { + "label": "Check readiness", + "prompt": "is Link ready?", + "when": "right after install or before troubleshooting", + }, + { + "label": "Prime memory", + "prompt": "brief me from Link before we continue", + "when": "at the start of a session or task", + }, + { + "label": "Save explicit memory", + "prompt": remember_prompt, + "when": "when you want future agents to remember a preference, decision, or project fact", + }, + { + "label": "Ask with context", + "prompt": query_prompt, + "when": "when you want a compact answer-ready packet from memory and wiki context", + }, + { + "label": "Ingest a source", + "prompt": "ingest raw/ into Link", + "when": "after dropping a source file into raw/", + }, + { + "label": "Review memory proposals", + "prompt": "propose memories from raw/", + "when": "when a source may contain preferences, decisions, or project context", + }, + ] + return { + "target": str(target), + "project": project_name, + "prompts": prompts, + "commands": [ + "link status --validate", + "link ingest-status", + "link memory-inbox", + 'link benchmark "agent memory"', + ], + } diff --git a/serve.py b/serve.py index d048712..5b5b859 100644 --- a/serve.py +++ b/serve.py @@ -48,6 +48,9 @@ from link_core.query import ( query_link as _core_query_link, ) +from link_core.prompts import ( + starter_prompt_payload as _core_starter_prompt_payload, +) from link_core.validation import ( validate_wiki as _core_validate_wiki, ) @@ -1660,6 +1663,7 @@ def _header_html():