From 56950fd7311ab4e71a8eb5d5ea27f96135556113 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 11:02:26 -0600 Subject: [PATCH 01/35] Serve logo from active Link root --- serve.py | 19 ++++++++++++++++--- tests/test_serve.py | 11 +++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/serve.py b/serve.py index 70146c0..8aef0ae 100644 --- a/serve.py +++ b/serve.py @@ -785,14 +785,27 @@ def _is_relative_to(path: Path, root: Path) -> bool: def _is_allowed_static_file(path: Path) -> bool: root = Path(__file__).parent.resolve() + link_root = WIKI_DIR.parent.resolve() return _core_is_allowed_static_file( path, RAW_DIR, - (root / "logo.svg", root / "logo.png"), + ( + link_root / "logo.svg", + link_root / "logo.png", + root / "logo.svg", + root / "logo.png", + ), RAW_STATIC_TYPES, ) +def _brand_file(name: str) -> Path: + link_asset = WIKI_DIR.parent / name + if link_asset.exists(): + return link_asset + return Path(__file__).parent / name + + def _resolve_raw_static_path(url_fragment: str) -> tuple[Path | None, str | None]: return _core_resolve_raw_static_path(RAW_DIR, url_fragment, RAW_STATIC_TYPES) @@ -1503,9 +1516,9 @@ def do_GET(self): parsed = urllib.parse.urlparse(self.path) path, query = parsed.path, urllib.parse.parse_qs(parsed.query) if path == "/logo.svg": - self._file(Path(__file__).parent / "logo.svg", "image/svg+xml") + self._file(_brand_file("logo.svg"), "image/svg+xml") elif path == "/logo.png": - self._file(Path(__file__).parent / "logo.png", "image/png") + self._file(_brand_file("logo.png"), "image/png") elif path.startswith("/raw/"): raw_path, content_type = _resolve_raw_static_path(path[5:]) if raw_path and content_type: diff --git a/tests/test_serve.py b/tests/test_serve.py index 357d37e..5ae4675 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -723,6 +723,17 @@ def test_static_file_allowlist_rejects_raw_traversal(self): self.assertFalse(serve._is_allowed_static_file(unsupported)) self.assertFalse(serve._is_allowed_static_file(denied)) + def test_logo_serves_from_configured_link_root(self): + wiki = self.make_wiki() + (wiki.parent / "logo.svg").write_text("", encoding="utf-8") + reset_wiki(wiki) + + status, body, headers = run_handler_raw("GET", "/logo.svg") + + self.assertEqual(status, 200) + self.assertEqual(body, b"") + self.assertEqual(headers["Content-Type"], "image/svg+xml") + def test_static_file_resolve_handles_malformed_paths(self): self.assertIsNone(serve._safe_resolve(Path("bad\0path"))) From fd985ddc23b08e92fab9497cfc898123b7f4b7e0 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 11:11:02 -0600 Subject: [PATCH 02/35] Clarify Link surfaces are independent --- README.md | 5 +++++ docs/cli.html | 1 + docs/concepts.html | 4 ++++ docs/getting-started.html | 4 ++++ docs/index.html | 4 ++++ docs/mcp.html | 4 ++++ docs/troubleshooting.html | 1 + docs/ui.html | 1 + 8 files changed, 24 insertions(+) diff --git a/README.md b/README.md index aa450b0..c36fd7f 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,11 @@ health signals without touching your real Link wiki. Pick the surface that matches how you work. They all read and write the same local Markdown wiki. +These surfaces are independent. `link serve` / `serve.py` is only the local web +viewer. CLI commands and MCP tools read the same `wiki/` files directly, so +Claude, Codex, Kiro, Cursor, or another MCP client can use Link even when the +web viewer is not running. + - +
diff --git a/docs/cli.html b/docs/cli.html index 87c5a67..2f5a552 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -57,6 +57,7 @@

CLI Tour

Animated Link CLI walkthrough
Status, query, brief, and benchmark are the core terminal loop.
+

The CLI is independent of the web viewer. Use link query, link brief, link health, and maintenance commands even when link serve is not running.

Daily Loop

link serve
diff --git a/docs/concepts.html b/docs/concepts.html
index 9fc1520..ac469a2 100644
--- a/docs/concepts.html
+++ b/docs/concepts.html
@@ -60,6 +60,10 @@ 

Storage Layers

Link architecture: raw sources, structured wiki, reviewed memory, and MCP retrieval
The wiki is the storage layer. Reviewed memory and MCP context are the product surface agents use.
+
+ One wiki, three independent surfaces + The web UI, CLI, and MCP server are separate ways to use the same local files. Closing the viewer does not stop CLI commands or MCP recall. +
Public repo versus local memory Link deliberately keeps generated raw/, wiki/, and link-demo/ content out of git. The tracked root wiki is scaffolding; python3 link.py demo creates the product-story demo wiki. diff --git a/docs/getting-started.html b/docs/getting-started.html index ab664d5..0022457 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -75,6 +75,10 @@

1. Run The Demo

Judge the generated demo The repo's root wiki/ is only a scaffold for local development and personal testing. Generated content in wiki/, raw/, and link-demo/ is ignored by git so private memory is not published by accident.
+
+ Viewer is optional + link serve is only for browsing Link in a local web UI. CLI commands and MCP-enabled agents work without it because they read the same local wiki/ files directly. +

The demo includes one pending memory intentionally, so the review inbox and explain-memory workflow are visible. Run link review-memory prefer-local-personal-memory link-demo if you want memory audit to be fully clear.

Open http://127.0.0.1:3000, then inspect /brief, /memory, /ingest, /graph, and /health. Open more for prompts, proposal review, audit, captures, profile, log, and all pages. Link accepts localhost too, but the numeric loopback address avoids slow IPv6 fallback in some Safari setups.

link query "why does Link help agents?" link-demo --budget small
diff --git a/docs/index.html b/docs/index.html
index 64eaeed..238604f 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -117,6 +117,10 @@ 

Run a finished memory wiki locally.

Three surfaces

Review it, script it, or let an agent call it.

The web UI, CLI, and MCP server all operate on the same local Markdown wiki. Read it like a local document, script it from a terminal, or let an agent query the same memory through MCP.

+
+ No background web server required + link serve only starts the human web viewer. The CLI and MCP server read the same local files directly, so agents can query Link when the viewer is closed. +
Animated Link web UI walkthrough diff --git a/docs/mcp.html b/docs/mcp.html index 7914fd7..0e6eb09 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -57,6 +57,10 @@

MCP Tour

Animated Link MCP agent walkthrough
Agents use natural prompts, then call Link tools for readiness, briefs, query packets, and reviewed memory writes.
+
+ MCP does not need serve.py + The local web viewer is only for humans. MCP clients start python -m link_mcp --wiki ... over stdio and read the same Markdown wiki directly. +

Agent Installers

Use the installer for the agent you use most. Installers create or update ~/link, install link-mcp, write short agent instructions, and preserve existing wiki data.

diff --git a/docs/troubleshooting.html b/docs/troubleshooting.html index 7f17b77..aac55de 100644 --- a/docs/troubleshooting.html +++ b/docs/troubleshooting.html @@ -65,6 +65,7 @@

MCP Is Not Visible

link verify-mcp
 python3 -m pip index versions link-mcp

Restart the MCP client after changing its config. If your installer printed a venv Python path, use that exact path in the MCP config.

+

You do not need link serve or serve.py running for MCP. The web viewer is separate from the MCP server.

Ingest Is Blocked

link ingest-status
diff --git a/docs/ui.html b/docs/ui.html index e3e64ba..6e24d6b 100644 --- a/docs/ui.html +++ b/docs/ui.html @@ -90,6 +90,7 @@

Start It

# from a source checkout python3 link.py serve link-demo

The server binds to 127.0.0.1 and is intended for local use. Do not expose it to the internet without adding your own authentication layer.

+

This viewer is optional. CLI commands and MCP clients keep working after you close the browser or stop serve.py.

From 4d884a38d5f2a524c8f9e21a34ee6fbf552e0297 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 12:24:16 -0600 Subject: [PATCH 03/35] Improve first-run and company readiness --- .github/workflows/ci.yml | 7 ++- CHANGELOG.md | 12 ++++ README.md | 16 +++++- SECURITY.md | 7 ++- docs/cli.html | 2 + docs/concepts.html | 2 +- docs/getting-started.html | 5 +- docs/index.html | 1 + docs/mcp.html | 3 +- docs/obsidian.html | 80 ++++++++++++++++++++++++++ docs/security.html | 5 +- link.py | 86 ++++++++++++++++++++++++++++ mcp_package/link_core/cli_parser.py | 15 +++++ mcp_package/link_core/cli_runtime.py | 45 +++++++++++++++ mcp_package/link_mcp/__main__.py | 4 +- mcp_package/link_mcp/server.py | 8 ++- scripts/check_tool_contract.py | 1 + tests/test_cli_parser_core.py | 30 ++++++++++ tests/test_cli_runtime_core.py | 26 +++++++++ tests/test_mcp_contract.py | 21 +++++++ 20 files changed, 361 insertions(+), 15 deletions(-) create mode 100644 docs/obsidian.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35456df..cd0c828 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,8 +81,11 @@ jobs: - name: Install MCP package for verification run: python -m pip install ./mcp_package - - name: Run portability tests - run: python -m unittest tests.test_mcp_verify_core tests.test_web_http_core tests.test_release_hygiene tests.test_cli_parser_core tests.test_cli_runtime_core + - name: Install test runner + run: python -m pip install "pytest>=8,<10" + + - name: Run broad Windows tests + run: python -m pytest tests -q --ignore=tests/test_installers.py --ignore=tests/test_serve.py --ignore=tests/test_large_wiki_smoke.py - name: Demo value-loop smoke test shell: pwsh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e89346..278df84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ## [Unreleased] +### Added + +- Added `link try` as a one-command demo proof loop that creates the demo, checks readiness, runs query/brief examples, and prints first agent prompts. +- Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. +- Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. + +### Changed + +- Broadened Windows CI from a small portability subset to most non-installer/non-server tests. +- Clarified that the Homebrew formula lives in the separate `gowtham0992/homebrew-link` tap. +- Tightened security reporting guidance to prefer private maintainer contact before public GitHub issues. + ## [1.3.0] - 2026-05-22 ### Added diff --git a/README.md b/README.md index c36fd7f..f325314 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ compound over time. [Web UI](https://gowtham0992.github.io/link/ui.html) · [MCP setup](https://gowtham0992.github.io/link/mcp.html) · [CLI](https://gowtham0992.github.io/link/cli.html) · +[Obsidian](https://gowtham0992.github.io/link/obsidian.html) · [Security](SECURITY.md) · [Changelog](CHANGELOG.md) @@ -52,8 +53,7 @@ macOS with Homebrew: ```bash brew install gowtham0992/link/link -link demo -link next link-demo +link try link serve link-demo ``` @@ -77,6 +77,13 @@ python3 link.py next link-demo python3 link.py serve link-demo ``` +Use `link try` for the shortest Homebrew proof loop. It creates the demo, +checks readiness, runs a compact query/brief proof, and prints the agent prompts +and viewer command. From source, use `python3 link.py try`. + +The Homebrew formula is maintained in the public +[`gowtham0992/homebrew-link`](https://github.com/gowtham0992/homebrew-link) tap. + Open: ```text @@ -200,6 +207,7 @@ what does Link remember about local personal memory? ```bash python3 -m pip install --upgrade link-mcp +python3 -m link_mcp --version ``` ```json @@ -224,6 +232,10 @@ python3 -m venv ~/.link-mcp-venv Full setup: [MCP guide](https://gowtham0992.github.io/link/mcp.html). +Obsidian users can open `~/link/wiki` directly as a vault. See the +[Obsidian guide](https://gowtham0992.github.io/link/obsidian.html) for the safe +edit and validation loop. + ## How Link Works Link separates source-backed knowledge from durable agent memory: diff --git a/SECURITY.md b/SECURITY.md index 1ed73fa..16afa86 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -49,6 +49,7 @@ working directory. ## Reporting vulnerabilities -Please report security issues through GitHub issues or private maintainer -contact channels. Avoid posting secrets, private wiki content, or raw source -files in public reports. +Please use a private maintainer contact channel for security issues first. Do +not post secrets, private wiki content, raw source files, or exploitable details +in public GitHub issues. If a public issue is the only available path, keep it +high level and ask for a private follow-up channel. diff --git a/docs/cli.html b/docs/cli.html index 2f5a552..9b184a6 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -58,6 +58,7 @@

CLI Tour

Status, query, brief, and benchmark are the core terminal loop.

The CLI is independent of the web viewer. Use link query, link brief, link health, and maintenance commands even when link serve is not running.

+

Use link try when you want the shortest proof loop: demo creation, readiness, query proof, brief proof, viewer command, and first agent prompts in one command.

Daily Loop

link serve
@@ -109,6 +110,7 @@ 

All Commands

link version link init [dir] link serve [dir] [--port 3000] +link try [dir] [--force] [--serve] [--port 3000] link welcome [dir] [--project slug] link prompts [dir] [--project slug] link next [dir] [--project slug] diff --git a/docs/concepts.html b/docs/concepts.html index ac469a2..6889514 100644 --- a/docs/concepts.html +++ b/docs/concepts.html @@ -74,7 +74,7 @@

Storage Layers

wiki/memories/Preferences, decisions, project facts, and user context.
indexesBacklinks, page index, local cache, token index, and optional SQLite FTS.
-

Markdown remains the source of truth. Derived indexes can be rebuilt. Agents maintain the files, but the files stay inspectable in git, Obsidian, or any editor.

+

Markdown remains the source of truth. Derived indexes can be rebuilt. Agents maintain the files, but the files stay inspectable in git, Obsidian, or any editor.

Three User Moves

Link deliberately separates knowledge from memory:

diff --git a/docs/getting-started.html b/docs/getting-started.html index 0022457..71ec9aa 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -56,9 +56,9 @@

1. Run The Demo

The demo is the fastest proof of value. It already has raw sources, wiki pages, memories, backlinks, and graph data.

macOS with Homebrew:

brew install gowtham0992/link/link
-link demo
-link next link-demo
+link try
 link serve link-demo
+

The Homebrew formula is maintained in the public gowtham0992/homebrew-link tap.

Or from source:

git clone https://github.com/gowtham0992/link.git
 cd link
@@ -71,6 +71,7 @@ 

1. Run The Demo

py link.py demo py link.py next link-demo py link.py serve link-demo
+

Use link try for the shortest Homebrew proof loop. It creates the demo, checks readiness, runs a compact query and brief, and prints the viewer command plus the first agent prompts. From source, use python3 link.py try or py link.py try.

Judge the generated demo The repo's root wiki/ is only a scaffold for local development and personal testing. Generated content in wiki/, raw/, and link-demo/ is ignored by git so private memory is not published by accident. diff --git a/docs/index.html b/docs/index.html index 238604f..d308f25 100644 --- a/docs/index.html +++ b/docs/index.html @@ -65,6 +65,7 @@

Not another notes app. A local memory layer agents can actually use.

Budgeted context

Smart query packets return the right memory, pages, graph neighborhood, and follow-up actions without flooding tokens.

Private by default

No hosted backend, no telemetry, no cloud lock-in. Your memory stays on disk as plain Markdown.

Auditable lifecycle

Capture, propose, approve, review, archive, restore, forget, and explain what Link remembers.

+

Obsidian-readable

Open the same wiki/ folder in Obsidian when you want a richer Markdown editor or graph view.

diff --git a/docs/mcp.html b/docs/mcp.html index 0e6eb09..cca33e9 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -74,7 +74,8 @@

Agent Installers

Use --project from a repo when memory should be project-scoped.

MCP Only

-
python3 -m pip install --upgrade link-mcp
+
python3 -m pip install --upgrade link-mcp
+python3 -m link_mcp --version
{
   "mcpServers": {
     "link": {
diff --git a/docs/obsidian.html b/docs/obsidian.html
new file mode 100644
index 0000000..4792f89
--- /dev/null
+++ b/docs/obsidian.html
@@ -0,0 +1,80 @@
+
+
+
+  
+  
+  
+  Link - Obsidian
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+
+  
+
+  
+
+ Plain Markdown +

Open Link in Obsidian when you want a richer notes UI.

+

Link owns the memory lifecycle. Obsidian can be a comfortable editor and graph viewer for the same local Markdown files.

+
+
+ +
+
+ +
+

Open The Link Wiki

+

In Obsidian, choose Open folder as vault and select your Link wiki folder:

+
~/link/wiki
+

For a project-local install, open that project's wiki/ folder instead. Link uses regular Markdown files, YAML frontmatter, and [[wikilinks]], so Obsidian can read the pages directly.

+ +

Edit Safely

+

Prefer editing prose, titles, aliases, tags, summaries, and source notes. Be careful with lifecycle fields on memory pages, such as status, review_status, scope, project, and source, because agents use those fields to decide what is safe to recall.

+
+ Memory rule + Use Link commands or MCP tools for durable memory writes when possible. They check duplicates, conflicts, review state, and audit logs. +
+ +

Keep Link Indexes Current

+

After manual Obsidian edits, rebuild derived indexes and validate the wiki before asking agents to rely on the new content:

+
link rebuild-index ~/link
+link rebuild-backlinks ~/link
+link validate ~/link
+link health ~/link
+

The local web viewer is still useful for Link-specific views like health, ingest, memory review, captures, and MCP-ready query context. Obsidian is an editor/viewer for the same underlying files, not a replacement for Link's validation and memory lifecycle.

+
+
+
+ +
+
Concepts · CLI reference · Security · If Link helps your agents remember better, star it on GitHub.
+

The installed Link product has no telemetry. This public docs site may use lightweight analytics to understand install interest.

+
+ + diff --git a/docs/security.html b/docs/security.html index 2f9b447..9d744e7 100644 --- a/docs/security.html +++ b/docs/security.html @@ -49,6 +49,7 @@

Your agent memory should belong to you.

HTTP boundary Backups Before sharing + Reporting

Privacy Model

@@ -89,7 +90,9 @@

Before Sharing A Repo Or Wiki

python3 scripts/check_release_hygiene.py git diff --check

Use git push, git archive, or clean build artifacts for public sharing. Do not zip a whole working directory; ignored local files, .git/, caches, raw sources, and build outputs can be included by accident.

-

See SECURITY.md for vulnerability reporting.

+

Reporting Security Issues

+

Use a private maintainer contact channel first. Do not post secrets, private wiki content, raw source files, or exploitable details in public GitHub issues. If a public issue is the only available path, keep it high level and ask for a private follow-up channel.

+

See SECURITY.md for the current reporting policy.

diff --git a/link.py b/link.py index 6b2cfcd..0bbe2ca 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 try [target] python link.py welcome [target] python link.py prompts [target] python link.py status [target] @@ -222,6 +223,7 @@ render_demo_text as _core_render_demo_text, render_init_text as _core_render_init_text, render_starter_prompts_text as _core_render_starter_prompts_text, + render_try_text as _core_render_try_text, render_welcome_text as _core_render_welcome_text, ) from link_core.prompts import ( @@ -1662,6 +1664,89 @@ def create_demo(target: Path, force: bool = False) -> int: return code +def _try_summary_from_query(payload: dict[str, object]) -> str: + wiki = payload.get("wiki") if isinstance(payload.get("wiki"), dict) else {} + memory = payload.get("memory") if isinstance(payload.get("memory"), dict) else {} + primary = wiki.get("primary") or "no primary page" + memory_items = memory.get("items") if isinstance(memory.get("items"), list) else [] + page_count = len(payload.get("context_packet") or []) if isinstance(payload.get("context_packet"), list) else 0 + return f"{primary} · {len(memory_items)} memories · {page_count} context items" + + +def _try_summary_from_brief(payload: dict[str, object]) -> str: + memories = payload.get("relevant_memories") if isinstance(payload.get("relevant_memories"), list) else [] + review = payload.get("review") if isinstance(payload.get("review"), dict) else {} + review_count = review.get("count", 0) + return f"{len(memories)} relevant memories · {review_count} review items" + + +def try_link( + target: Path, + *, + force: bool = False, + serve: bool = False, + port: int = 3000, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + created = False + if force or not (target / "wiki").exists(): + try: + _core_create_demo_workspace(target, source_root=ROOT, force=force) + except _CoreDemoError as exc: + print(str(exc), file=sys.stderr) + return 1 + created = True + + wiki_dir = _resolve_wiki_dir(target) + status_payload = _core_link_status(wiki_dir, version=LINK_VERSION, include_validation=True) + query_payload = _query_link(wiki_dir, "why does Link help agents?", budget="small") + brief_payload = _memory_brief(wiki_dir, "working on agent memory", limit=6) + payload = { + "target": str(target), + "created": created, + "ready": bool(status_payload.get("ready")), + "status": status_payload, + "query": query_payload, + "brief": brief_payload, + "commands": { + "serve": _display_command(["link", "serve", str(target), "--port", str(port)]), + "next": _display_command(["link", "next", str(target)]), + "health": _display_command(["link", "health", str(target)]), + "query": _display_command(["link", "query", "why does Link help agents?", str(target), "--budget", "small"]), + "brief": _display_command(["link", "brief", "working on agent memory", str(target)]), + "benchmark": _display_command(["link", "benchmark", "agent memory", str(target)]), + }, + "url": f"http://127.0.0.1:{port}", + } + if json_output: + print(json.dumps(payload, indent=2)) + if serve: + return serve_wiki(target, port=port) + return 0 if payload["ready"] else 1 + + code, text = _core_render_try_text( + target=target, + ready=payload["ready"], + page_count=status_payload.get("page_count", 0), + memory_count=status_payload.get("memory_count", 0), + search_backend=status_payload.get("search_backend", "unknown"), + query_summary=_try_summary_from_query(query_payload), + brief_summary=_try_summary_from_brief(brief_payload), + serve_command=payload["commands"]["serve"], + next_command=payload["commands"]["next"], + health_command=payload["commands"]["health"], + query_command=payload["commands"]["query"], + brief_command=payload["commands"]["brief"], + benchmark_command=payload["commands"]["benchmark"], + url=payload["url"], + ) + print(text) + if serve: + return serve_wiki(target, port=port) + return code + + def main(argv: list[str] | None = None) -> int: parser = _core_build_cli_parser(default_demo_dir=DEFAULT_DEMO_DIR) args = parser.parse_args(argv) @@ -1670,6 +1755,7 @@ def main(argv: list[str] | None = None) -> int: "init": init_wiki, "serve": serve_wiki, "demo": create_demo, + "try": try_link, "welcome": welcome, "prompts": starter_prompts, "status": status, diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index ed5c3c8..eeecd47 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -33,6 +33,13 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum demo.add_argument("target", nargs="?", default=default_demo_dir) demo.add_argument("--force", action="store_true", help="replace an existing Link demo directory") + try_cmd = sub.add_parser("try", help="create the demo and print the shortest proof loop") + try_cmd.add_argument("target", nargs="?", default=default_demo_dir) + try_cmd.add_argument("--force", action="store_true", help="replace an existing Link demo directory") + try_cmd.add_argument("--serve", action="store_true", help="start the local viewer after printing the proof loop") + try_cmd.add_argument("--port", type=int, default=3000) + try_cmd.add_argument("--json", action="store_true", help="print machine-readable try data") + welcome_cmd = sub.add_parser("welcome", help="print the shortest first-use path for Link") welcome_cmd.add_argument("target", nargs="?", default=".") welcome_cmd.add_argument("--project", default=None, help="project slug for project-scoped prompt examples") @@ -258,6 +265,14 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: return handlers["serve"](Path(args.target), port=args.port) if command == "demo": return handlers["demo"](Path(args.target), force=args.force) + if command == "try": + return handlers["try"]( + Path(args.target), + force=args.force, + serve=args.serve, + port=args.port, + json_output=args.json, + ) if command == "welcome": return handlers["welcome"](Path(args.target), project=args.project, json_output=args.json) if command in {"prompts", "next"}: diff --git a/mcp_package/link_core/cli_runtime.py b/mcp_package/link_core/cli_runtime.py index 7fdf842..ff00a10 100644 --- a/mcp_package/link_core/cli_runtime.py +++ b/mcp_package/link_core/cli_runtime.py @@ -96,3 +96,48 @@ def render_demo_text( " http://127.0.0.1:3000", " http://127.0.0.1:3000/graph", ]) + + +def render_try_text( + *, + target: object, + ready: bool, + page_count: object, + memory_count: object, + search_backend: object, + query_summary: str, + brief_summary: str, + serve_command: str, + next_command: str, + health_command: str, + query_command: str, + brief_command: str, + benchmark_command: str, + url: str, +) -> tuple[int, str]: + status_text = "ready" if ready else "needs attention" + return 0 if ready else 1, "\n".join([ + f"Link try: {target}", + "", + f"Demo: {status_text} · {page_count} pages · {memory_count} memories · {search_backend}", + f"Query proof: {query_summary}", + f"Brief proof: {brief_summary}", + "", + "Open the local viewer:", + f" {serve_command}", + f" {url}", + "", + "Ask an agent:", + " is Link ready?", + " brief me from Link before we continue", + " what does Link remember about local personal memory?", + "", + "Run the value loop:", + f" {query_command}", + f" {brief_command}", + f" {benchmark_command}", + f" {health_command}", + "", + "More first-run prompts:", + f" {next_command}", + ]) diff --git a/mcp_package/link_mcp/__main__.py b/mcp_package/link_mcp/__main__.py index eec0db0..646621f 100644 --- a/mcp_package/link_mcp/__main__.py +++ b/mcp_package/link_mcp/__main__.py @@ -1,5 +1,5 @@ """Entry point: python -m link_mcp""" -from link_mcp.server import mcp +from link_mcp.server import main if __name__ == "__main__": - mcp.run(transport="stdio") + main() diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 4cc3ba1..ae04adf 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -29,11 +29,18 @@ import sys from pathlib import Path +from link_core.version import LINK_VERSION + # ── Resolve wiki directory ──────────────────────────────────────────── parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--wiki", default=None) +parser.add_argument("--version", action="store_true") args, _ = parser.parse_known_args() +if args.version: + print(f"link-mcp {LINK_VERSION}") + sys.exit(0) + if args.wiki: WIKI_DIR = Path(args.wiki).expanduser().resolve() else: @@ -167,7 +174,6 @@ from link_core.validation import ( validate_wiki as _core_validate_wiki, ) -from link_core.version import LINK_VERSION from link_core.status import ( link_status as _core_link_status, ) diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 0cacb16..efe1da9 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -44,6 +44,7 @@ "review-memory", "serve", "status", + "try", "update-memory", "validate", "version", diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index a86ea9e..0f8db63 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -30,6 +30,18 @@ def test_query_alias_and_budget_options(self): self.assertEqual(args.budget, "small") self.assertTrue(args.json) + def test_try_command_options(self): + parser = build_cli_parser(default_demo_dir="custom-demo") + + args = parser.parse_args(["try", "--force", "--serve", "--port", "3456", "--json"]) + + self.assertEqual(args.command, "try") + self.assertEqual(args.target, "custom-demo") + self.assertTrue(args.force) + self.assertTrue(args.serve) + self.assertEqual(args.port, 3456) + self.assertTrue(args.json) + def test_operations_limit_and_json_options(self): parser = build_cli_parser() @@ -105,6 +117,24 @@ def query_handler(target, query, **kwargs): self.assertEqual(calls[0][2]["budget"], "small") self.assertTrue(calls[0][2]["json_output"]) + def test_dispatch_routes_try_arguments(self): + parser = build_cli_parser() + args = parser.parse_args(["try", "/tmp/link-demo", "--force", "--serve", "--port", "3456", "--json"]) + calls = [] + + def try_handler(target, **kwargs): + calls.append((target, kwargs)) + return 5 + + code = dispatch_cli_command(args, {"try": try_handler}) + + self.assertEqual(code, 5) + self.assertEqual(calls[0][0], Path("/tmp/link-demo")) + self.assertTrue(calls[0][1]["force"]) + self.assertTrue(calls[0][1]["serve"]) + self.assertEqual(calls[0][1]["port"], 3456) + self.assertTrue(calls[0][1]["json_output"]) + def test_dispatch_routes_operations_arguments(self): parser = build_cli_parser() args = parser.parse_args(["operations", "/tmp/link", "--limit", "5", "--json"]) diff --git a/tests/test_cli_runtime_core.py b/tests/test_cli_runtime_core.py index 5d81fb1..d4d2794 100644 --- a/tests/test_cli_runtime_core.py +++ b/tests/test_cli_runtime_core.py @@ -4,6 +4,7 @@ render_demo_text, render_init_text, render_starter_prompts_text, + render_try_text, render_welcome_text, ) @@ -78,6 +79,31 @@ def test_render_demo_text(self): self.assertIn("/tmp/link-demo/START_HERE.md", text) self.assertIn("http://127.0.0.1:3000/graph", text) + def test_render_try_text(self): + code, text = render_try_text( + target="/tmp/link-demo", + ready=True, + page_count=13, + memory_count=1, + search_backend="sqlite-fts", + query_summary="agent-memory · 1 memories · 3 context items", + brief_summary="1 relevant memories · 1 review items", + serve_command="link serve /tmp/link-demo", + next_command="link next /tmp/link-demo", + health_command="link health /tmp/link-demo", + query_command="link query 'why does Link help agents?' /tmp/link-demo --budget small", + brief_command="link brief 'working on agent memory' /tmp/link-demo", + benchmark_command="link benchmark 'agent memory' /tmp/link-demo", + url="http://127.0.0.1:3000", + ) + + self.assertEqual(code, 0) + self.assertIn("Link try: /tmp/link-demo", text) + self.assertIn("Demo: ready", text) + self.assertIn("Query proof:", text) + self.assertIn("Ask an agent:", text) + self.assertIn("link next /tmp/link-demo", text) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 97f7a6e..0c559b8 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -213,6 +213,27 @@ def test_missing_wiki_message_points_to_current_setup_paths(self): finally: sys.argv = previous_argv + def test_version_flag_does_not_require_wiki_or_mcp_sdk(self): + previous_argv = sys.argv[:] + missing = Path(tempfile.mkdtemp(prefix="link-mcp-version-")) / "missing" / "wiki" + module_name = f"link_mcp_server_version_{id(missing)}" + try: + sys.argv = ["link_mcp.server", "--wiki", str(missing), "--version"] + spec = importlib.util.spec_from_file_location(module_name, ROOT / "mcp_package/link_mcp/server.py") + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + out = StringIO() + err = StringIO() + with redirect_stdout(out), redirect_stderr(err), self.assertRaises(SystemExit) as cm: + spec.loader.exec_module(module) + + self.assertEqual(cm.exception.code, 0) + self.assertIn("link-mcp", out.getvalue()) + self.assertEqual(err.getvalue(), "") + finally: + sys.modules.pop(module_name, None) + sys.argv = previous_argv + def test_migrate_wiki_contract(self): (self.target / "wiki/_link_schema.json").unlink() From 22d1d16ac2fd65599e3daf9987665f89aaf2deed Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 13:48:43 -0600 Subject: [PATCH 04/35] Throttle MCP cache mtime checks --- mcp_package/link_mcp/server.py | 17 +++++++++++++++-- tests/test_mcp_contract.py | 11 +++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index ae04adf..d8980aa 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -27,6 +27,7 @@ import argparse import json import sys +import time from pathlib import Path from link_core.version import LINK_VERSION @@ -104,6 +105,8 @@ # ── In-memory indexes (built on first use, invalidated by mtime) ────── _cache: dict = {} _cache_mtime: float = 0.0 +_cache_checked_at: float = 0.0 +CACHE_MTIME_CHECK_INTERVAL_SECONDS = 0.5 MAX_TEXT_INPUT = 200 MAX_CAPTURE_INPUT = 12000 @@ -243,15 +246,25 @@ def _wiki_mtime() -> float: def _clear_cache() -> None: - global _cache, _cache_mtime + global _cache, _cache_mtime, _cache_checked_at _core_close_wiki_cache(_cache) _cache = {} _cache_mtime = 0.0 + _cache_checked_at = 0.0 def _build_cache() -> dict: - global _cache, _cache_mtime + global _cache, _cache_mtime, _cache_checked_at + now = time.monotonic() + if ( + _cache + and CACHE_MTIME_CHECK_INTERVAL_SECONDS > 0 + and now - _cache_checked_at < CACHE_MTIME_CHECK_INTERVAL_SECONDS + ): + return _cache + mtime = _wiki_mtime() + _cache_checked_at = now if _cache and mtime == _cache_mtime: return _cache diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 0c559b8..06fd99a 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -147,6 +147,17 @@ def test_link_status_contract(self): self.assertEqual(payload["warnings"], []) self.assertEqual(payload["next_actions"][0]["tool"], "query_link") + def test_mcp_cache_throttles_repeated_mtime_scans(self): + self.server._clear_cache() + self.server.CACHE_MTIME_CHECK_INTERVAL_SECONDS = 60.0 + self.server._build_cache() + + with patch.object(self.server, "_wiki_mtime", wraps=self.server._wiki_mtime) as mtime: + self.server._build_cache() + self.server._build_cache() + + self.assertEqual(mtime.call_count, 0) + def test_link_status_contract_reports_cache_warnings(self): locked = self.target / "wiki/concepts/locked-page.md" locked.write_text("---\ntype: concept\ntitle: Locked\n---\n\n# Locked\n", encoding="utf-8") From 01863e565c0aff2c525e7ba97b080ada3d14d67e Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 13:55:01 -0600 Subject: [PATCH 05/35] Polish README product opening --- README.md | 74 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f325314..a4b96cc 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,52 @@

- Link + Link

-# Link +

Link

-**Local, source-backed memory for LLM agents.** +

Local memory for AI agents.

-Link gives Codex, Claude, Cursor, Kiro, VS Code, Copilot, and other MCP clients -the same durable memory about you and your work. It stays on your machine as -plain Markdown, with sources, backlinks, graph context, review state, and an -audit trail you can inspect. +

+ Link gives Codex, Claude, Cursor, Kiro, VS Code, Copilot, Antigravity, and + other MCP clients the same source-backed memory, stored locally as Markdown. +

-It follows Andrej Karpathy's -[LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f): -keep knowledge outside the chat window, make claims inspectable, and let context -compound over time. +

+ Website · + Quick start · + MCP setup · + CLI · + MCP Registry · + PyPI · + Homebrew +

-[![GitHub](https://img.shields.io/github/stars/gowtham0992/link?style=flat)](https://github.com/gowtham0992/link) -[![CI](https://github.com/gowtham0992/link/actions/workflows/ci.yml/badge.svg)](https://github.com/gowtham0992/link/actions/workflows/ci.yml) -[![MCP Registry](https://img.shields.io/badge/MCP_Registry-io.github.gowtham0992%2Flink-blue)](https://registry.modelcontextprotocol.io/?q=io.github.gowtham0992%2Flink) -[![PyPI](https://img.shields.io/pypi/v/link-mcp)](https://pypi.org/project/link-mcp/) +

+ GitHub stars + CI + MCP Registry + PyPI +

-[Product site](https://gowtham0992.github.io/link/) · -[First 10 minutes](https://gowtham0992.github.io/link/getting-started.html) · -[Why Link?](https://gowtham0992.github.io/link/why-link.html) · -[Web UI](https://gowtham0992.github.io/link/ui.html) · -[MCP setup](https://gowtham0992.github.io/link/mcp.html) · -[CLI](https://gowtham0992.github.io/link/cli.html) · -[Obsidian](https://gowtham0992.github.io/link/obsidian.html) · -[Security](SECURITY.md) · -[Changelog](CHANGELOG.md) +## What Is Link? -## Why It Exists +Link is an open-source memory layer for local AI agents. Raw sources become an +inspectable Markdown wiki. Explicit "remember this" requests become reviewable +memories. Agents retrieve compact, source-backed context through MCP without +dumping the whole wiki into a chat window. + +The wiki is the storage layer. The product is durable memory that stays on your +machine, remains readable in plain files, and can be shared across multiple +agents instead of locked inside one vendor profile. + +## How It Works + +Link gives agents four simple moves: + +1. **Capture** notes, transcripts, docs, screenshots, and project context in `raw/`. +2. **Structure** source-backed pages under `wiki/`. +3. **Remember** explicit preferences, decisions, facts, and project context as reviewable memory. +4. **Retrieve** compact query packets through CLI, MCP, or the local web viewer. Most agent sessions start from zero. You re-explain preferences, repo decisions, project constraints, and why something matters. Link turns that repeated context @@ -44,6 +59,11 @@ into local memory agents can query. | Context windows are expensive. | Return compact query packets with provenance and follow-up actions. | | Memory needs trust. | Every page and memory can be inspected, reviewed, archived, or forgotten. | +Link follows Andrej Karpathy's +[LLM Wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f): +keep knowledge outside the chat window, make claims inspectable, and let context +compound over time. + ## Quick Start Run the demo first. It creates a complete local wiki with raw sources, wiki @@ -236,9 +256,9 @@ Obsidian users can open `~/link/wiki` directly as a vault. See the [Obsidian guide](https://gowtham0992.github.io/link/obsidian.html) for the safe edit and validation loop. -## How Link Works +## Storage Model -Link separates source-backed knowledge from durable agent memory: +Under the hood, Link separates source-backed knowledge from durable agent memory: 1. Drop raw notes, transcripts, articles, and project context into `raw/`. 2. Agents compile those sources into inspectable pages under `wiki/`. From cede09d35221bd865f54e5f7714033ec44346dbc Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 14:10:22 -0600 Subject: [PATCH 06/35] Add Windows PowerShell installers --- .github/workflows/ci.yml | 7 + README.md | 14 +- docs/contributing.html | 1 + docs/getting-started.html | 9 +- docs/mcp.html | 12 +- integrations/README.md | 36 ++-- integrations/_shared/instructions.ps1 | 159 ++++++++++++++++++ integrations/_shared/scaffold.ps1 | 227 ++++++++++++++++++++++++++ integrations/antigravity/install.ps1 | 34 ++++ integrations/claude-code/install.ps1 | 34 ++++ integrations/codex/install.ps1 | 49 ++++++ integrations/copilot/install.ps1 | 28 ++++ integrations/cursor/install.ps1 | 39 +++++ integrations/kiro/install.ps1 | 37 +++++ integrations/vscode/install.ps1 | 55 +++++++ tests/test_installers.py | 57 +++++++ 16 files changed, 779 insertions(+), 19 deletions(-) create mode 100644 integrations/_shared/instructions.ps1 create mode 100644 integrations/_shared/scaffold.ps1 create mode 100644 integrations/antigravity/install.ps1 create mode 100644 integrations/claude-code/install.ps1 create mode 100644 integrations/codex/install.ps1 create mode 100644 integrations/copilot/install.ps1 create mode 100644 integrations/cursor/install.ps1 create mode 100644 integrations/kiro/install.ps1 create mode 100644 integrations/vscode/install.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd0c828..f0ec202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,13 @@ jobs: - name: Check shell syntax run: bash -n integrations/*/install.sh integrations/*/uninstall.sh integrations/_shared/*.sh + - name: Check PowerShell syntax + shell: pwsh + run: | + Get-ChildItem integrations -Recurse -Include *.ps1 | ForEach-Object { + [scriptblock]::Create((Get-Content -Raw $_.FullName)) | Out-Null + } + release-hygiene: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index a4b96cc..7278290 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,17 @@ Installers create or update `~/link`, install or upgrade `link-mcp`, write lightweight agent instructions, and preserve existing wiki data on reinstall. Use `--project` when a repo needs separate project memory. -The shell installers are intended for macOS/Linux-style agent config paths. -On Windows, use the source commands above plus the MCP-only config below until -your agent has a Windows-specific installer. +On Windows, use the matching PowerShell installer: + +```powershell +.\integrations\codex\install.ps1 +.\integrations\kiro\install.ps1 +.\integrations\claude-code\install.ps1 +.\integrations\cursor\install.ps1 +.\integrations\copilot\install.ps1 +.\integrations\vscode\install.ps1 +.\integrations\antigravity\install.ps1 +``` Then ask your agent: diff --git a/docs/contributing.html b/docs/contributing.html index 5057fa3..f954c7c 100644 --- a/docs/contributing.html +++ b/docs/contributing.html @@ -67,6 +67,7 @@

Before Opening A PR

python3 scripts/check_runtime_duplication.py python3 scripts/check_tool_contract.py bash -n integrations/*/install.sh integrations/*/uninstall.sh integrations/_shared/*.sh +pwsh -NoProfile -Command "Get-ChildItem integrations -Recurse -Include *.ps1 | ForEach-Object { [scriptblock]::Create((Get-Content -Raw $_.FullName)) | Out-Null }" python3 link.py demo /tmp/link-mcp-smoke --force PYTHONPATH=mcp_package python3 scripts/smoke_mcp_stdio.py /tmp/link-mcp-smoke/wiki git diff --check
diff --git a/docs/getting-started.html b/docs/getting-started.html index 71ec9aa..2717112 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -97,7 +97,14 @@

2. Install Link For Your Agent

bash integrations/vscode/install.sh bash integrations/antigravity/install.sh

Use --project for a repo-local Link install. Project-scoped memory then stays separate from other project memory while still allowing broad user memory to be recalled.

-

The shell installers target macOS/Linux-style agent config paths. On Windows, run Link from source and use the MCP-only setup in the MCP guide until a Windows-specific installer exists.

+

On Windows PowerShell, use the matching install.ps1 script:

+
.\integrations\codex\install.ps1
+.\integrations\kiro\install.ps1
+.\integrations\claude-code\install.ps1
+.\integrations\cursor\install.ps1
+.\integrations\copilot\install.ps1
+.\integrations\vscode\install.ps1
+.\integrations\antigravity\install.ps1

3. Add One Source

Open the local viewer and use ingest -> Add Raw Source, or write a first note directly:

diff --git a/docs/mcp.html b/docs/mcp.html index cca33e9..46cb85c 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -72,6 +72,14 @@

Agent Installers

bash integrations/vscode/install.sh bash integrations/antigravity/install.sh

Use --project from a repo when memory should be project-scoped.

+

On Windows PowerShell, use .\integrations\AGENT\install.ps1 instead:

+
.\integrations\codex\install.ps1
+.\integrations\kiro\install.ps1
+.\integrations\claude-code\install.ps1
+.\integrations\cursor\install.ps1
+.\integrations\copilot\install.ps1
+.\integrations\vscode\install.ps1
+.\integrations\antigravity\install.ps1

MCP Only

python3 -m pip install --upgrade link-mcp
@@ -93,8 +101,8 @@ 

MCP Only

{
   "mcpServers": {
     "link": {
-      "command": "/Users/YOU/.link-mcp-venv/bin/python",
-      "args": ["-m", "link_mcp", "--wiki", "/Users/YOU/link/wiki"]
+      "command": "C:\\Users\\YOU\\.link-mcp-venv\\Scripts\\python.exe",
+      "args": ["-m", "link_mcp", "--wiki", "C:\\Users\\YOU\\link\\wiki"]
     }
   }
 }
diff --git a/integrations/README.md b/integrations/README.md index 1f29d57..337ce91 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -10,6 +10,13 @@ git clone https://github.com/gowtham0992/link.git ~/link-repo bash ~/link-repo/integrations/codex/install.sh ``` +On Windows PowerShell: + +```powershell +git clone https://github.com/gowtham0992/link.git $HOME\link-repo +& $HOME\link-repo\integrations\codex\install.ps1 +``` + Pick the installer that matches your agent. After install, try: ```text @@ -20,21 +27,21 @@ query Link for what you know about this project ## All integrations -| Tool | Command | Global location | -|------|---------|----------------| -| Kiro | `bash integrations/kiro/install.sh` | `~/.kiro/steering/link.md` | -| Claude Code | `bash integrations/claude-code/install.sh` | `~/.claude/CLAUDE.md` | -| Antigravity | `bash integrations/antigravity/install.sh` | `~/.gemini/GEMINI.md` | -| Codex | `bash integrations/codex/install.sh` | `~/AGENTS.md` | -| Cursor | `bash integrations/cursor/install.sh` | `~/.cursor/rules/link.mdc` | -| Copilot | `bash integrations/copilot/install.sh` | `.github/copilot-instructions.md` | -| VS Code | `bash integrations/vscode/install.sh` | `.vscode/settings.json` | +| Tool | macOS/Linux | Windows PowerShell | Global location | +|------|-------------|--------------------|-----------------| +| Kiro | `bash integrations/kiro/install.sh` | `.\integrations\kiro\install.ps1` | `~/.kiro/steering/link.md` | +| Claude Code | `bash integrations/claude-code/install.sh` | `.\integrations\claude-code\install.ps1` | `~/.claude/CLAUDE.md` | +| Antigravity | `bash integrations/antigravity/install.sh` | `.\integrations\antigravity\install.ps1` | `~/.gemini/GEMINI.md` | +| Codex | `bash integrations/codex/install.sh` | `.\integrations\codex\install.ps1` | `~/AGENTS.md` | +| Cursor | `bash integrations/cursor/install.sh` | `.\integrations\cursor\install.ps1` | `~/.cursor/rules/link.mdc` | +| Copilot | `bash integrations/copilot/install.sh` | `.\integrations\copilot\install.ps1` | `.github/copilot-instructions.md` | +| VS Code | `bash integrations/vscode/install.sh` | `.\integrations\vscode\install.ps1` | `.vscode/settings.json` | ## Two modes -- **Default (global):** `bash install.sh` — installs tool instructions globally + scaffolds central wiki at `~/link/`. One wiki for everything. +- **Default (global):** `bash install.sh` or `.\install.ps1` — installs tool instructions globally + scaffolds central wiki at `~/link/`. One wiki for everything. -- **Project-local:** `bash install.sh --project` — installs instructions in current project + scaffolds wiki here. For team projects that need their own wiki. +- **Project-local:** `bash install.sh --project` or `.\install.ps1 -Project` — installs instructions in current project + scaffolds wiki here. For team projects that need their own wiki. ## What the install does @@ -42,7 +49,7 @@ query Link for what you know about this project 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 health`. +5. Adds a short `link` command wrapper for global installs, so checks are short: `link health`. 6. Prints next prompts and verification commands for your install mode. The instruction file is intentionally small. It tells the agent to check @@ -52,4 +59,7 @@ needs the full local protocol. ## Uninstall -Each folder has an `uninstall.sh`. Same `--project` flag applies. +Each folder has an `uninstall.sh`. Same `--project` flag applies. PowerShell +uninstall scripts are not needed yet because `install.ps1` only writes the same +small instruction/config files listed above; remove those files or delete the +`link` MCP entry from the relevant JSON config if you need to undo it manually. diff --git a/integrations/_shared/instructions.ps1 b/integrations/_shared/instructions.ps1 new file mode 100644 index 0000000..3cbcd75 --- /dev/null +++ b/integrations/_shared/instructions.ps1 @@ -0,0 +1,159 @@ +param() + +$ErrorActionPreference = "Stop" + +function Link-NewParentDirectory { + param([Parameter(Mandatory = $true)][string]$Path) + + $parent = Split-Path -Parent $Path + if ($parent) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } +} + +function Link-UpsertInstructions { + param( + [Parameter(Mandatory = $true)][string]$Target, + [Parameter(Mandatory = $true)][string]$SourceFile, + [Parameter(Mandatory = $true)][string]$Label + ) + + Link-NewParentDirectory $Target + $source = (Get-Content -Raw -Encoding UTF8 $SourceFile).TrimEnd() + $existing = "" + if (Test-Path $Target) { + $existing = Get-Content -Raw -Encoding UTF8 $Target + } + + $headers = @("## Link — Local Agent Memory", "## Link — Personal Knowledge Wiki") + $headerPattern = ($headers | ForEach-Object { [regex]::Escape($_) }) -join "|" + $pattern = "(?s)(^|`n)(?:$headerPattern)`n.*?(?=`n## |\z)" + + if ([regex]::IsMatch($existing, $pattern)) { + $updated = [regex]::Replace($existing, $pattern, { + param($match) + $prefix = if ($match.Groups[1].Value) { "`n" } else { "" } + return $prefix + $source + }).TrimEnd() + "`n" + } else { + $separator = if ($existing.Trim()) { "`n`n" } else { "" } + $updated = $existing.TrimEnd() + $separator + $source + "`n" + } + + Set-Content -Encoding UTF8 -NoNewline -Path $Target -Value $updated + Write-Host "$Label -> $Target" +} + +function Link-ToHashtable { + param($InputObject) + + if ($null -eq $InputObject) { + return @{} + } + if ($InputObject -is [System.Collections.IDictionary]) { + $out = @{} + foreach ($key in $InputObject.Keys) { + $out[$key] = Link-ToHashtable $InputObject[$key] + } + return $out + } + if ($InputObject -is [System.Management.Automation.PSCustomObject]) { + $out = @{} + foreach ($property in $InputObject.PSObject.Properties) { + $out[$property.Name] = Link-ToHashtable $property.Value + } + return $out + } + if ($InputObject -is [System.Array]) { + return @($InputObject | ForEach-Object { Link-ToHashtable $_ }) + } + return $InputObject +} + +function Link-UpsertMcpJson { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Command, + [Parameter(Mandatory = $true)][string]$WikiPath, + [string]$TopKey = "mcpServers", + [switch]$IncludeType, + [switch]$IncludeDisabled + ) + + Link-NewParentDirectory $Path + $config = @{} + if (Test-Path $Path) { + try { + $raw = Get-Content -Raw -Encoding UTF8 $Path + if ($raw.Trim()) { + $config = Link-ToHashtable ($raw | ConvertFrom-Json) + } + } catch { + Write-Host " · Could not parse $Path; leaving it unchanged." + Write-Host " Add manually: $Command -m link_mcp --wiki $WikiPath" + return + } + } + + if (-not $config.ContainsKey($TopKey) -or -not ($config[$TopKey] -is [System.Collections.IDictionary])) { + $config[$TopKey] = @{} + } + + $server = @{ + command = $Command + args = @("-m", "link_mcp", "--wiki", $WikiPath) + } + if ($IncludeType) { + $server["type"] = "stdio" + } + if ($IncludeDisabled) { + $server["disabled"] = $false + } + + $config[$TopKey]["link"] = $server + $json = $config | ConvertTo-Json -Depth 20 + Set-Content -Encoding UTF8 -Path $Path -Value ($json + "`n") + Write-Host " ✓ Link MCP registered in $Path" +} + +function Link-ReadMcpPython { + param([Parameter(Mandatory = $true)][string]$WikiPath) + + $root = Split-Path -Parent $WikiPath + $marker = Join-Path $root ".link-mcp-python" + if (Test-Path $marker) { + $value = (Get-Content -Raw -Encoding UTF8 $marker).Trim() + if ($value) { + return $value + } + } + return "py" +} + +function Link-PrintNextSteps { + param([string]$Mode = "--global") + + Write-Host "" + Write-Host "Done." + if ($Mode -eq "--project") { + Write-Host " Drop sources into raw/." + Write-Host " View wiki: py link.py serve" + Write-Host " Print starter prompts: py link.py next" + Write-Host " Try in your agent:" + Write-Host " is Link ready?" + Write-Host " brief me from Link before we continue" + Write-Host " remember that this project uses Link for local agent memory" + Write-Host " query Link for what this project remembers" + Write-Host " ingest raw/ into Link" + } else { + Write-Host " Drop sources into ~/link/raw/." + Write-Host " View wiki: link serve" + Write-Host " Print starter prompts: link next" + Write-Host " Try in your agent:" + Write-Host " is Link ready?" + Write-Host " brief me from Link before we continue" + Write-Host " remember that I prefer local-first agent memory" + Write-Host " query Link for what you know about me" + Write-Host " ingest raw/ into Link" + } +} diff --git a/integrations/_shared/scaffold.ps1 b/integrations/_shared/scaffold.ps1 new file mode 100644 index 0000000..9ce64cf --- /dev/null +++ b/integrations/_shared/scaffold.ps1 @@ -0,0 +1,227 @@ +param([switch]$Project) + +$ErrorActionPreference = "Stop" + +$ScriptDir = Split-Path -Parent $PSCommandPath +$LinkRoot = (Resolve-Path (Join-Path $ScriptDir "..\..")).Path +$Mode = if ($Project) { "--project" } else { "--global" } +$TargetDir = if ($Project) { (Get-Location).Path } else { Join-Path $HOME "link" } +$BasePython = if (Get-Command py -ErrorAction SilentlyContinue) { + "py" +} elseif (Get-Command python -ErrorAction SilentlyContinue) { + "python" +} else { + throw "Python was not found. Install Python 3 and rerun this installer." +} + +if (-not $Project) { + New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null +} + +function Copy-LinkFile { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination, + [string]$Label = "" + ) + + if (Test-Path $Source) { + $parent = Split-Path -Parent $Destination + if ($parent) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } + Copy-Item -Force -Path $Source -Destination $Destination + if ($Label) { + Write-Host " Updated $Label" + } + } +} + +function Install-LinkCommandWrapper { + if ($Project -or -not (Test-Path (Join-Path $TargetDir "link.py"))) { + return + } + + $cliDir = if ($env:LINK_CLI_DIR) { $env:LINK_CLI_DIR } else { Join-Path $HOME ".local\bin" } + $cmdPath = Join-Path $cliDir "link.cmd" + $psPath = Join-Path $cliDir "link.ps1" + $marker = "Link command wrapper" + $linkPy = Join-Path $TargetDir "link.py" + + New-Item -ItemType Directory -Force -Path $cliDir | Out-Null + + if ((Test-Path $cmdPath) -and -not (Select-String -Quiet -SimpleMatch $marker $cmdPath)) { + Write-Host " · $cmdPath already exists and is not a Link wrapper; not overwriting." + Write-Host " Fallback: $BasePython `"$linkPy`" health" + return + } + + $cmd = @" +@echo off +REM $marker +$BasePython "$linkPy" %* +"@ + Set-Content -Encoding ASCII -Path $cmdPath -Value $cmd + + $ps = @" +# $marker +& $BasePython "$linkPy" @args +exit `$LASTEXITCODE +"@ + Set-Content -Encoding UTF8 -Path $psPath -Value $ps + + Write-Host " ✓ Link command: $cmdPath" + $pathParts = ($env:PATH -split [IO.Path]::PathSeparator) + if ($pathParts -notcontains $cliDir) { + Write-Host " · Add $cliDir to PATH to run: link health" + } +} + +$isUpdate = (Test-Path (Join-Path $TargetDir "wiki\index.md")) -or (Test-Path (Join-Path $TargetDir "wiki\log.md")) +if ($isUpdate) { + Write-Host " Existing wiki detected at $TargetDir - updating code only, wiki data untouched." +} else { + Write-Host " Fresh install at $TargetDir." +} + +Copy-LinkFile (Join-Path $LinkRoot "serve.py") (Join-Path $TargetDir "serve.py") "serve.py" +Copy-LinkFile (Join-Path $LinkRoot "LINK.md") (Join-Path $TargetDir "LINK.md") "LINK.md" +Copy-LinkFile (Join-Path $LinkRoot "link.py") (Join-Path $TargetDir "link.py") "link.py" +Copy-LinkFile (Join-Path $LinkRoot "logo.png") (Join-Path $TargetDir "logo.png") +Copy-LinkFile (Join-Path $LinkRoot "logo.svg") (Join-Path $TargetDir "logo.svg") +Copy-LinkFile (Join-Path $LinkRoot ".linkignore") (Join-Path $TargetDir ".linkignore") + +$coreDir = Join-Path $LinkRoot "mcp_package\link_core" +if (Test-Path $coreDir) { + $targetCore = Join-Path $TargetDir "link_core" + New-Item -ItemType Directory -Force -Path $targetCore | Out-Null + Copy-Item -Force -Path (Join-Path $coreDir "*.py") -Destination $targetCore + Write-Host " Updated link_core" +} + +$dirs = @( + "raw", + "wiki\sources", + "wiki\concepts", + "wiki\entities", + "wiki\memories", + "wiki\comparisons", + "wiki\explorations" +) +foreach ($dir in $dirs) { + $path = Join-Path $TargetDir $dir + New-Item -ItemType Directory -Force -Path $path | Out-Null + if (-not $isUpdate) { + New-Item -ItemType File -Force -Path (Join-Path $path ".gitkeep") | Out-Null + } +} + +if (-not $isUpdate) { + $backlinks = Join-Path $TargetDir "wiki\_backlinks.json" + if (-not (Test-Path $backlinks)) { + Set-Content -Encoding UTF8 -Path $backlinks -Value "{`n `"backlinks`": {},`n `"forward`": {}`n}`n" + Write-Host " Created wiki/_backlinks.json" + } + Copy-LinkFile (Join-Path $LinkRoot "wiki\index.md") (Join-Path $TargetDir "wiki\index.md") + Copy-LinkFile (Join-Path $LinkRoot "wiki\log.md") (Join-Path $TargetDir "wiki\log.md") + Write-Host " Wiki structure created at $TargetDir" +} + +Write-Host " Wiki ready at $TargetDir" +Install-LinkCommandWrapper + +Write-Host "" +Write-Host " Setting up MCP server..." + +$linkMcpPackage = if (Test-Path (Join-Path $LinkRoot "mcp_package")) { + Join-Path $LinkRoot "mcp_package" +} else { + "link-mcp" +} +$mcpPython = $BasePython +$venv = if ($env:LINK_MCP_VENV) { $env:LINK_MCP_VENV } else { Join-Path $HOME ".link-mcp-venv" } +$venvPython = Join-Path $venv "Scripts\python.exe" +$marker = Join-Path $TargetDir ".link-mcp-python" +$installed = $false +$reused = $false + +& $BasePython -m pip install --upgrade $linkMcpPackage -q *> $null +if ($LASTEXITCODE -eq 0) { + $installed = $true + $mcpPython = $BasePython +} else { + & $BasePython -m venv $venv *> $null + if ($LASTEXITCODE -eq 0 -and (Test-Path $venvPython)) { + & $venvPython -m pip install --upgrade pip -q *> $null + if ($LASTEXITCODE -eq 0) { + & $venvPython -m pip install --upgrade $linkMcpPackage -q *> $null + if ($LASTEXITCODE -eq 0) { + $installed = $true + $mcpPython = $venvPython + } + } + } +} + +if (-not $installed -and (Test-Path $marker)) { + $candidate = (Get-Content -Raw -Encoding UTF8 $marker).Trim() + if ($candidate) { + & $candidate -c "import link_mcp" *> $null + if ($LASTEXITCODE -eq 0) { + $installed = $true + $reused = $true + $mcpPython = $candidate + } + } +} elseif (-not $installed -and (Test-Path $venvPython)) { + & $venvPython -c "import link_mcp" *> $null + if ($LASTEXITCODE -eq 0) { + $installed = $true + $reused = $true + $mcpPython = $venvPython + } +} + +if ($installed) { + Set-Content -Encoding UTF8 -Path $marker -Value ($mcpPython + "`n") + if ($reused) { + Write-Host " ✓ existing link-mcp available" + Write-Host " · Automatic upgrade did not complete; run verify-mcp to confirm the installed version." + } else { + Write-Host " ✓ link-mcp installed" + } + if ($mcpPython -ne $BasePython) { + Write-Host " ✓ MCP Python: $mcpPython" + } + Write-Host "" + Write-Host " Add to your MCP client config:" + Write-Host " {" + Write-Host " `"mcpServers`": {" + Write-Host " `"link`": {" + Write-Host " `"command`": `"$mcpPython`"," + Write-Host " `"args`": [`"-m`", `"link_mcp`", `"--wiki`", `"$TargetDir\wiki`"]" + Write-Host " }" + Write-Host " }" + Write-Host " }" +} else { + Write-Host " · Could not install link-mcp automatically." + Write-Host " Manual options:" + Write-Host " $BasePython -m pip install --upgrade link-mcp" + Write-Host " $BasePython -m venv ~/.link-mcp-venv" + Write-Host " ~\.link-mcp-venv\Scripts\python.exe -m pip install --upgrade pip link-mcp" +} + +if (Test-Path (Join-Path $TargetDir "link.py")) { + Write-Host "" + if ($Project) { + Write-Host " Check Link readiness:" + Write-Host " py link.py health" + Write-Host " Verify MCP setup:" + Write-Host " py link.py verify-mcp" + } else { + Write-Host " Check Link readiness:" + Write-Host " link health" + Write-Host " Verify MCP setup:" + Write-Host " link verify-mcp" + } +} diff --git a/integrations/antigravity/install.ps1 b/integrations/antigravity/install.ps1 new file mode 100644 index 0000000..453c615 --- /dev/null +++ b/integrations/antigravity/install.ps1 @@ -0,0 +1,34 @@ +param([switch]$Project) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir "..\_shared\instructions.ps1") + +$mode = if ($Project) { "--project" } else { "--global" } +$instructionsFile = if ($Project) { + Join-Path $ScriptDir "..\_shared\link-instructions-project.md" +} else { + Join-Path $ScriptDir "..\_shared\link-instructions.md" +} +$target = if ($Project) { "GEMINI.md" } else { Join-Path $HOME ".gemini\GEMINI.md" } +$wikiPath = if ($Project) { Join-Path (Get-Location).Path "wiki" } else { Join-Path $HOME "link\wiki" } + +Link-UpsertInstructions $target $instructionsFile "Link instructions" + +if ($Project) { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") -Project +} else { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") +} + +$mcpPython = Link-ReadMcpPython $wikiPath +$settings = Join-Path $HOME ".gemini\settings.json" +if (-not $Project -and (Test-Path $settings)) { + Link-UpsertMcpJson -Path $settings -Command $mcpPython -WikiPath $wikiPath +} else { + Write-Host "" + Write-Host " MCP: add to $settings:" + Write-Host " { `"mcpServers`": { `"link`": { `"command`": `"$mcpPython`", `"args`": [`"-m`", `"link_mcp`", `"--wiki`", `"$wikiPath`"] } } }" +} + +Link-PrintNextSteps $mode diff --git a/integrations/claude-code/install.ps1 b/integrations/claude-code/install.ps1 new file mode 100644 index 0000000..53ed48a --- /dev/null +++ b/integrations/claude-code/install.ps1 @@ -0,0 +1,34 @@ +param([switch]$Project) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir "..\_shared\instructions.ps1") + +$mode = if ($Project) { "--project" } else { "--global" } +$instructionsFile = if ($Project) { + Join-Path $ScriptDir "..\_shared\link-instructions-project.md" +} else { + Join-Path $ScriptDir "..\_shared\link-instructions.md" +} +$target = if ($Project) { "CLAUDE.md" } else { Join-Path $HOME ".claude\CLAUDE.md" } +$wikiPath = if ($Project) { Join-Path (Get-Location).Path "wiki" } else { Join-Path $HOME "link\wiki" } + +Link-UpsertInstructions $target $instructionsFile "Link steering" + +if ($Project) { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") -Project +} else { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") +} + +$mcpPython = Link-ReadMcpPython $wikiPath +$mcpConfig = Join-Path $HOME ".claude.json" +if (Test-Path $mcpConfig) { + Link-UpsertMcpJson -Path $mcpConfig -Command $mcpPython -WikiPath $wikiPath +} else { + Write-Host "" + Write-Host " MCP config: add to $mcpConfig or .mcp.json at project root:" + Write-Host " { `"mcpServers`": { `"link`": { `"command`": `"$mcpPython`", `"args`": [`"-m`", `"link_mcp`", `"--wiki`", `"$wikiPath`"] } } }" +} + +Link-PrintNextSteps $mode diff --git a/integrations/codex/install.ps1 b/integrations/codex/install.ps1 new file mode 100644 index 0000000..5455842 --- /dev/null +++ b/integrations/codex/install.ps1 @@ -0,0 +1,49 @@ +param([switch]$Project) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir "..\_shared\instructions.ps1") + +$mode = if ($Project) { "--project" } else { "--global" } +$instructionsFile = if ($Project) { + Join-Path $ScriptDir "..\_shared\link-instructions-project.md" +} else { + Join-Path $ScriptDir "..\_shared\link-instructions.md" +} +$target = if ($Project) { "AGENTS.md" } else { Join-Path $HOME "AGENTS.md" } +$wikiPath = if ($Project) { Join-Path (Get-Location).Path "wiki" } else { Join-Path $HOME "link\wiki" } + +Link-UpsertInstructions $target $instructionsFile "Link instructions" + +if ($Project) { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") -Project +} else { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") +} + +$mcpPython = Link-ReadMcpPython $wikiPath +$codexConfig = Join-Path $HOME ".codex\config.toml" +if (Test-Path $codexConfig) { + $command = $mcpPython | ConvertTo-Json -Compress + $wiki = $wikiPath | ConvertTo-Json -Compress + $block = "[mcp_servers.link]`ncommand = $command`nargs = [`"-m`", `"link_mcp`", `"--wiki`", $wiki]`n" + $text = Get-Content -Raw -Encoding UTF8 $codexConfig + $pattern = "(?ms)^\[mcp_servers\.link\]\r?\n.*?(?=^\[|\z)" + if ([regex]::IsMatch($text, $pattern)) { + $text = [regex]::Replace($text, $pattern, $block) + if (-not $text.EndsWith("`n")) { + $text += "`n" + } + } else { + $text = $text.TrimEnd() + "`n`n" + $block + } + Set-Content -Encoding UTF8 -NoNewline -Path $codexConfig -Value $text + Write-Host " ✓ Link MCP registered in $codexConfig" +} else { + Write-Host " MCP config: add to $codexConfig:" + Write-Host " [mcp_servers.link]" + Write-Host " command = `"$mcpPython`"" + Write-Host " args = [`"-m`", `"link_mcp`", `"--wiki`", `"$wikiPath`"]" +} + +Link-PrintNextSteps $mode diff --git a/integrations/copilot/install.ps1 b/integrations/copilot/install.ps1 new file mode 100644 index 0000000..228b4d2 --- /dev/null +++ b/integrations/copilot/install.ps1 @@ -0,0 +1,28 @@ +param([switch]$Project) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir "..\_shared\instructions.ps1") + +$mode = if ($Project) { "--project" } else { "--global" } +$instructionsFile = if ($Project) { + Join-Path $ScriptDir "..\_shared\link-instructions-project.md" +} else { + Join-Path $ScriptDir "..\_shared\link-instructions.md" +} +$wikiPath = if ($Project) { Join-Path (Get-Location).Path "wiki" } else { Join-Path $HOME "link\wiki" } + +Link-UpsertInstructions ".github\copilot-instructions.md" $instructionsFile "Link instructions" + +if ($Project) { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") -Project +} else { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") +} + +$mcpPython = Link-ReadMcpPython $wikiPath +Write-Host "" +Write-Host " MCP: add to your Copilot MCP config:" +Write-Host " { `"mcpServers`": { `"link`": { `"command`": `"$mcpPython`", `"args`": [`"-m`", `"link_mcp`", `"--wiki`", `"$wikiPath`"] } } }" + +Link-PrintNextSteps $mode diff --git a/integrations/cursor/install.ps1 b/integrations/cursor/install.ps1 new file mode 100644 index 0000000..500ba81 --- /dev/null +++ b/integrations/cursor/install.ps1 @@ -0,0 +1,39 @@ +param([switch]$Project) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir "..\_shared\instructions.ps1") + +$mode = if ($Project) { "--project" } else { "--global" } +$instructionsFile = if ($Project) { + Join-Path $ScriptDir "..\_shared\link-instructions-project.md" +} else { + Join-Path $ScriptDir "..\_shared\link-instructions.md" +} +$target = if ($Project) { ".cursor\rules\link.mdc" } else { Join-Path $HOME ".cursor\rules\link.mdc" } +$wikiPath = if ($Project) { Join-Path (Get-Location).Path "wiki" } else { Join-Path $HOME "link\wiki" } + +Link-NewParentDirectory $target +$instructions = Get-Content -Raw -Encoding UTF8 $instructionsFile +$rule = "---`ndescription: Link knowledge wiki context`nalwaysApply: true`n---`n`n$instructions" +Set-Content -Encoding UTF8 -Path $target -Value $rule +Write-Host "Link rule -> $target" + +if ($Project) { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") -Project +} else { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") +} + +$mcpPython = Link-ReadMcpPython $wikiPath +if (-not $Project) { + $mcpConfig = Join-Path $HOME ".cursor\mcp.json" + if (Test-Path $mcpConfig) { + Link-UpsertMcpJson -Path $mcpConfig -Command $mcpPython -WikiPath $wikiPath + } else { + Write-Host " Add to $mcpConfig:" + Write-Host " { `"mcpServers`": { `"link`": { `"command`": `"$mcpPython`", `"args`": [`"-m`", `"link_mcp`", `"--wiki`", `"$wikiPath`"] } } }" + } +} + +Link-PrintNextSteps $mode diff --git a/integrations/kiro/install.ps1 b/integrations/kiro/install.ps1 new file mode 100644 index 0000000..ecfe71e --- /dev/null +++ b/integrations/kiro/install.ps1 @@ -0,0 +1,37 @@ +param([switch]$Project) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir "..\_shared\instructions.ps1") + +$mode = if ($Project) { "--project" } else { "--global" } +$instructionsFile = if ($Project) { + Join-Path $ScriptDir "..\_shared\link-instructions-project.md" +} else { + Join-Path $ScriptDir "..\_shared\link-instructions.md" +} +$target = if ($Project) { ".kiro\steering\link.md" } else { Join-Path $HOME ".kiro\steering\link.md" } +$wikiPath = if ($Project) { Join-Path (Get-Location).Path "wiki" } else { Join-Path $HOME "link\wiki" } + +Link-NewParentDirectory $target +Copy-Item -Force -Path $instructionsFile -Destination $target +Write-Host "Link steering -> $target" + +if ($Project) { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") -Project +} else { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") +} + +$mcpPython = Link-ReadMcpPython $wikiPath +if (-not $Project) { + $mcpConfig = Join-Path $HOME ".kiro\settings\mcp.json" + if (Test-Path $mcpConfig) { + Link-UpsertMcpJson -Path $mcpConfig -Command $mcpPython -WikiPath $wikiPath -IncludeDisabled + } else { + Write-Host " MCP config: add to $mcpConfig:" + Write-Host " { `"mcpServers`": { `"link`": { `"command`": `"$mcpPython`", `"args`": [`"-m`", `"link_mcp`", `"--wiki`", `"$wikiPath`"], `"disabled`": false } } }" + } +} + +Link-PrintNextSteps $mode diff --git a/integrations/vscode/install.ps1 b/integrations/vscode/install.ps1 new file mode 100644 index 0000000..bfafcc4 --- /dev/null +++ b/integrations/vscode/install.ps1 @@ -0,0 +1,55 @@ +param([switch]$Project) + +$ErrorActionPreference = "Stop" +$ScriptDir = Split-Path -Parent $PSCommandPath +. (Join-Path $ScriptDir "..\_shared\instructions.ps1") + +$mode = if ($Project) { "--project" } else { "--global" } +$instructionsFile = if ($Project) { + Join-Path $ScriptDir "..\_shared\link-instructions-project.md" +} else { + Join-Path $ScriptDir "..\_shared\link-instructions.md" +} +$wikiPath = if ($Project) { Join-Path (Get-Location).Path "wiki" } else { Join-Path $HOME "link\wiki" } + +New-Item -ItemType Directory -Force -Path ".vscode" | Out-Null +$target = ".vscode\settings.json" +$settings = @{} +if (Test-Path $target) { + try { + $raw = Get-Content -Raw -Encoding UTF8 $target + if ($raw.Trim()) { + $settings = Link-ToHashtable ($raw | ConvertFrom-Json) + } + } catch { + $settings = @{} + } +} +$key = "github.copilot.chat.codeGeneration.instructions" +$instructionsText = Get-Content -Raw -Encoding UTF8 $instructionsFile +$items = @() +if ($settings.ContainsKey($key) -and ($settings[$key] -is [System.Array])) { + $items = @($settings[$key] | Where-Object { + $text = if ($_ -is [System.Collections.IDictionary]) { $_["text"] } else { $_.text } + -not ( + $text -like "*## Link — Local Agent Memory*" -or + $text -like "*## Link — Personal Knowledge Wiki*" -or + $text -like "*Link, an LLM-maintained knowledge wiki*" + ) + }) +} +$items += @{ text = $instructionsText } +$settings[$key] = $items +Set-Content -Encoding UTF8 -Path $target -Value (($settings | ConvertTo-Json -Depth 20) + "`n") +Write-Host "Link instructions -> $target" + +if ($Project) { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") -Project +} else { + & (Join-Path $ScriptDir "..\_shared\scaffold.ps1") +} + +$mcpPython = Link-ReadMcpPython $wikiPath +Link-UpsertMcpJson -Path ".vscode\mcp.json" -Command $mcpPython -WikiPath $wikiPath -TopKey "servers" -IncludeType + +Link-PrintNextSteps $mode diff --git a/tests/test_installers.py b/tests/test_installers.py index ddd2bc9..d57ed69 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -14,6 +14,15 @@ ROOT / "integrations/kiro/install.sh", ROOT / "integrations/vscode/install.sh", ] +POWERSHELL_INSTALLERS = [ + ROOT / "integrations/antigravity/install.ps1", + ROOT / "integrations/claude-code/install.ps1", + ROOT / "integrations/codex/install.ps1", + ROOT / "integrations/copilot/install.ps1", + ROOT / "integrations/cursor/install.ps1", + ROOT / "integrations/kiro/install.ps1", + ROOT / "integrations/vscode/install.ps1", +] class InstallerTests(unittest.TestCase): @@ -41,6 +50,17 @@ def test_scaffold_project_mode_uses_absolute_target(self): self.assertIn('TARGET_DIR="$(pwd)"', scaffold) self.assertNotIn('TARGET_DIR="."', scaffold) + def test_powershell_scaffold_uses_venv_and_short_link_command(self): + scaffold = (ROOT / "integrations/_shared/scaffold.ps1").read_text(encoding="utf-8") + + self.assertNotIn("--break-system-packages", scaffold) + self.assertIn(".link-mcp-venv", scaffold) + self.assertIn(".link-mcp-python", scaffold) + self.assertIn("link.cmd", scaffold) + self.assertIn("Link command wrapper", scaffold) + self.assertIn("Get-Command py", scaffold) + self.assertIn("-m venv", scaffold) + def test_installers_read_resolved_mcp_python_marker(self): for installer in INSTALLERS: with self.subTest(installer=installer.name): @@ -48,6 +68,12 @@ def test_installers_read_resolved_mcp_python_marker(self): self.assertIn("MCP_PYTHON", text) self.assertIn(".link-mcp-python", text) + for installer in POWERSHELL_INSTALLERS: + with self.subTest(installer=installer.name): + text = installer.read_text(encoding="utf-8") + self.assertIn("Link-ReadMcpPython", text) + self.assertIn("scaffold.ps1", text) + def test_installers_print_mode_specific_next_steps(self): instructions = (ROOT / "integrations/_shared/instructions.sh").read_text(encoding="utf-8") @@ -70,6 +96,37 @@ def test_installers_print_mode_specific_next_steps(self): self.assertIn('. "$SCRIPT_DIR/../_shared/instructions.sh"', text) self.assertIn('link_print_next_steps "$MODE"', text) + instructions_ps1 = (ROOT / "integrations/_shared/instructions.ps1").read_text(encoding="utf-8") + self.assertIn("function Link-PrintNextSteps", instructions_ps1) + self.assertIn("py link.py next", instructions_ps1) + self.assertIn("Try in your agent:", instructions_ps1) + self.assertIn("is Link ready?", instructions_ps1) + + for installer in POWERSHELL_INSTALLERS: + with self.subTest(installer=installer.name): + text = installer.read_text(encoding="utf-8") + self.assertIn("instructions.ps1", text) + self.assertIn("Link-PrintNextSteps", text) + + def test_windows_installers_are_documented(self): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + integrations = (ROOT / "integrations/README.md").read_text(encoding="utf-8") + getting_started = (ROOT / "docs/getting-started.html").read_text(encoding="utf-8") + mcp = (ROOT / "docs/mcp.html").read_text(encoding="utf-8") + + for name in ["codex", "kiro", "claude-code", "cursor", "copilot", "vscode", "antigravity"]: + self.assertIn(f".\\integrations\\{name}\\install.ps1", readme) + self.assertIn(f".\\integrations\\{name}\\install.ps1", integrations) + self.assertIn(f".\\integrations\\{name}\\install.ps1", getting_started) + self.assertIn(f".\\integrations\\{name}\\install.ps1", mcp) + + def test_ci_checks_powershell_installer_syntax(self): + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + + self.assertIn("Check PowerShell syntax", workflow) + self.assertIn("[scriptblock]::Create", workflow) + self.assertIn("*.ps1", workflow) + 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") From 59598fb866c7620ec2e4e0a7defbc4e574264f4f Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 20:27:16 -0600 Subject: [PATCH 07/35] Add MCP agent connect command --- CHANGELOG.md | 1 + README.md | 11 ++ docs/cli.html | 6 + docs/mcp.html | 8 + link.py | 39 +++++ mcp_package/README.md | 9 + mcp_package/link_core/cli_parser.py | 17 ++ mcp_package/link_core/cli_runtime.py | 46 ++++++ mcp_package/link_core/mcp_connect.py | 236 +++++++++++++++++++++++++++ scripts/check_tool_contract.py | 1 + tests/test_cli_parser_core.py | 52 ++++++ tests/test_cli_runtime_core.py | 23 +++ tests/test_mcp_connect_core.py | 137 ++++++++++++++++ 13 files changed, 586 insertions(+) create mode 100644 mcp_package/link_core/mcp_connect.py create mode 100644 tests/test_mcp_connect_core.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 278df84..dd9fd6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ### Added - Added `link try` as a one-command demo proof loop that creates the demo, checks readiness, runs query/brief examples, and prints first agent prompts. +- Added `link connect ` to preview or write MCP client config for Codex, Kiro, Claude Code, Cursor, Antigravity, VS Code, and Copilot. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/README.md b/README.md index 7278290..9408dae 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,17 @@ query Link for the release process what does Link remember about local personal memory? ``` +If your agent already has instructions and you only need MCP wiring, use the +connection helper. It previews the exact config first; add `--write` when you +want Link to update the agent config file. + +```bash +link connect codex ~/link +link connect codex ~/link --write +link connect kiro ~/link --write +link verify-mcp ~/link +``` +
MCP-only install diff --git a/docs/cli.html b/docs/cli.html index 9b184a6..8058392 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -102,6 +102,11 @@

Maintenance

link validate link verify-mcp

Use link backup before broad repair work. Use link benchmark when a wiki starts to feel slow. link status --validate and link benchmark both show persistent-cache reuse so you can tell whether Link is rereading every page or reusing unchanged records.

+

Use link connect <agent> when an agent already has Link instructions but still needs MCP wiring. It previews the config before writing.

+
link connect codex ~/link
+link connect codex ~/link --write
+link connect kiro ~/link --write
+link verify-mcp ~/link

From a source checkout, use the synthetic large-wiki smoke when you want local scale evidence without touching your real wiki. The script prints the exact link serve command and graph URL for the generated fixture.

python3 scripts/smoke_large_wiki.py --pages 10000
@@ -147,6 +152,7 @@

All Commands

link rebuild-index link rebuild-backlinks link verify-mcp [--json] +link connect <agent> [dir] [--write] [--config path] [--python python] python3 link.py demo python3 link.py query-link "task" [dir]

query-link is kept as an internal/backward-compatible alias. Prefer link query in user-facing docs.

diff --git a/docs/mcp.html b/docs/mcp.html index 46cb85c..8408cc9 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -80,6 +80,14 @@

Agent Installers

.\integrations\copilot\install.ps1 .\integrations\vscode\install.ps1 .\integrations\antigravity\install.ps1 +

If you already have agent instructions and only need the MCP config, use link connect. It previews the target file and config snippet first; add --write for an explicit update.

+
link connect codex ~/link
+link connect codex ~/link --write
+link connect kiro ~/link --write
+link connect claude-code ~/link --write
+link connect cursor ~/link --write
+link connect antigravity ~/link --write
+link verify-mcp ~/link

MCP Only

python3 -m pip install --upgrade link-mcp
diff --git a/link.py b/link.py
index 0bbe2ca..2d80a80 100644
--- a/link.py
+++ b/link.py
@@ -36,6 +36,7 @@
   python link.py rebuild-index [target]
   python link.py rebuild-backlinks [target]
   python link.py verify-mcp [target]
+  python link.py connect  [target]
 """
 from __future__ import annotations
 
@@ -202,6 +203,9 @@
     display_command as _core_display_command,
     render_mcp_verify_text as _core_render_mcp_verify_text,
 )
+from link_core.mcp_connect import (
+    build_mcp_connect_payload as _core_build_mcp_connect_payload,
+)
 from link_core.operations import (
     operation_report as _core_operation_report,
     render_operations_text as _core_render_operations_text,
@@ -222,6 +226,7 @@
 from link_core.cli_runtime import (
     render_demo_text as _core_render_demo_text,
     render_init_text as _core_render_init_text,
+    render_mcp_connect_text as _core_render_mcp_connect_text,
     render_starter_prompts_text as _core_render_starter_prompts_text,
     render_try_text as _core_render_try_text,
     render_welcome_text as _core_render_welcome_text,
@@ -1564,6 +1569,39 @@ def verify_mcp(
     return code
 
 
+def connect_mcp(
+    target: Path,
+    agent: str,
+    *,
+    write: bool = False,
+    config_path: str | None = None,
+    python_cmd: str | None = None,
+    json_output: bool = False,
+) -> int:
+    target = target.expanduser().resolve()
+    wiki_dir = _resolve_wiki_dir(target)
+    payload = _core_build_mcp_connect_payload(
+        target=target,
+        wiki_dir=wiki_dir,
+        agent=agent,
+        expected_version=LINK_VERSION,
+        init_command=[sys.executable, str(ROOT / "link.py"), "init", str(target)],
+        python_cmd=python_cmd,
+        default_python=sys.executable,
+        config_path=config_path,
+        write=write,
+    )
+
+    if json_output:
+        print(json.dumps(payload, indent=2))
+        write_status = payload.get("write") if isinstance(payload.get("write"), dict) else {}
+        return 0 if not write or bool(write_status.get("ok")) else 1
+
+    code, text = _core_render_mcp_connect_text(payload)
+    print(text)
+    return code
+
+
 def _copy_runtime_files(target: Path) -> None:
     _core_copy_runtime_files(ROOT, target)
 
@@ -1790,6 +1828,7 @@ def main(argv: list[str] | None = None) -> int:
             "rebuild-index": rebuild_index,
             "rebuild-backlinks": rebuild_backlinks,
             "verify-mcp": verify_mcp,
+            "connect": connect_mcp,
             "version": lambda: print(f"Link {LINK_VERSION}") or 0,
         })
     except ValueError as exc:
diff --git a/mcp_package/README.md b/mcp_package/README.md
index 0542657..a35cb43 100644
--- a/mcp_package/README.md
+++ b/mcp_package/README.md
@@ -23,6 +23,15 @@ bash link/integrations/codex/install.sh   # or claude-code, cursor, kiro, vscode
 The installer scaffolds `~/link/`, installs or upgrades `link-mcp`, writes agent
 instructions, and prints the exact MCP config for your machine.
 
+If Link is already installed and you only need to wire MCP into an agent, use
+the CLI helper from the main Link package:
+
+```bash
+link connect codex ~/link
+link connect codex ~/link --write
+link verify-mcp ~/link
+```
+
 After install, ask your agent:
 
 ```text
diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py
index eeecd47..dc6eb66 100644
--- a/mcp_package/link_core/cli_parser.py
+++ b/mcp_package/link_core/cli_parser.py
@@ -251,6 +251,14 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum
     verify_mcp_cmd.add_argument("--json", action="store_true", help="print machine-readable status")
     verify_mcp_cmd.add_argument("--python", default=None, help="Python executable to verify")
 
+    connect_cmd = sub.add_parser("connect", help="print or write MCP config for a local agent")
+    connect_cmd.add_argument("agent", help="agent to connect: codex, kiro, claude-code, cursor, antigravity, vscode, copilot")
+    connect_cmd.add_argument("target", nargs="?", default=".")
+    connect_cmd.add_argument("--write", action="store_true", help="update the detected agent config file")
+    connect_cmd.add_argument("--config", default=None, help="override the agent config file path")
+    connect_cmd.add_argument("--python", default=None, help="Python executable for the MCP server")
+    connect_cmd.add_argument("--json", action="store_true", help="print machine-readable connection plan")
+
     return parser
 
 
@@ -439,4 +447,13 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int:
         return handlers["rebuild-backlinks"](Path(args.target))
     if command == "verify-mcp":
         return handlers["verify-mcp"](Path(args.target), json_output=args.json, python_cmd=args.python)
+    if command == "connect":
+        return handlers["connect"](
+            Path(args.target),
+            args.agent,
+            write=args.write,
+            config_path=args.config,
+            python_cmd=args.python,
+            json_output=args.json,
+        )
     raise ValueError(f"unknown command: {command}")
diff --git a/mcp_package/link_core/cli_runtime.py b/mcp_package/link_core/cli_runtime.py
index ff00a10..05e9d1a 100644
--- a/mcp_package/link_core/cli_runtime.py
+++ b/mcp_package/link_core/cli_runtime.py
@@ -141,3 +141,49 @@ def render_try_text(
         "More first-run prompts:",
         f"  {next_command}",
     ])
+
+
+def render_mcp_connect_text(payload: Mapping[str, object]) -> tuple[int, str]:
+    """Render a safe MCP connection plan for a local agent."""
+    write_status = payload.get("write") if isinstance(payload.get("write"), Mapping) else {}
+    requested = bool(write_status.get("requested"))
+    ok = bool(write_status.get("ok"))
+    code = 0 if not requested or ok else 1
+    lines = [
+        f"Link connect: {payload.get('display_name')}",
+        "",
+        f"Wiki: {payload.get('wiki')}",
+        f"Python: {payload.get('python')}",
+        f"Config: {payload.get('config_path')}",
+        "",
+    ]
+    if requested:
+        lines.append(f"Write: {'updated' if ok else 'failed'}")
+        message = write_status.get("message")
+        if message:
+            lines.append(f"  {message}")
+        lines.append("")
+    else:
+        lines.extend([
+            "Preview only. To update the agent config:",
+        ])
+        actions = payload.get("next_actions", [])
+        if isinstance(actions, Sequence) and not isinstance(actions, (str, bytes)):
+            for action in actions:
+                if isinstance(action, Mapping) and action.get("label") == "write config":
+                    lines.append(f"  {action.get('command_text')}")
+                    break
+        lines.append("")
+    lines.append("Config snippet:")
+    snippet = str(payload.get("snippet") or "")
+    lines.extend(f"  {line}" if line else "" for line in snippet.splitlines())
+    lines.extend(["", "Then:"])
+    actions = payload.get("next_actions", [])
+    if isinstance(actions, Sequence) and not isinstance(actions, (str, bytes)):
+        for action in actions:
+            if isinstance(action, Mapping) and action.get("label") != "write config":
+                lines.append(f"  {action.get('command_text')}")
+    restart_hint = payload.get("restart_hint")
+    if restart_hint:
+        lines.append(f"  {restart_hint}")
+    return code, "\n".join(lines)
diff --git a/mcp_package/link_core/mcp_connect.py b/mcp_package/link_core/mcp_connect.py
new file mode 100644
index 0000000..7b24bac
--- /dev/null
+++ b/mcp_package/link_core/mcp_connect.py
@@ -0,0 +1,236 @@
+"""MCP client configuration helpers for Link."""
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Mapping
+
+from .files import atomic_write_json, atomic_write_text
+from .mcp_verify import display_command, resolve_mcp_python
+
+
+@dataclass(frozen=True)
+class AgentMcpConfig:
+    name: str
+    display_name: str
+    aliases: tuple[str, ...]
+    default_config: str
+    config_format: str
+    top_key: str = "mcpServers"
+    include_type: bool = False
+    include_disabled: bool = False
+    restart_hint: str = "Restart the agent, then ask: is Link ready?"
+
+
+AGENT_CONFIGS: tuple[AgentMcpConfig, ...] = (
+    AgentMcpConfig(
+        name="codex",
+        display_name="Codex",
+        aliases=("codex",),
+        default_config="~/.codex/config.toml",
+        config_format="codex-toml",
+    ),
+    AgentMcpConfig(
+        name="kiro",
+        display_name="Kiro",
+        aliases=("kiro",),
+        default_config="~/.kiro/settings/mcp.json",
+        config_format="json",
+        include_disabled=True,
+    ),
+    AgentMcpConfig(
+        name="claude-code",
+        display_name="Claude Code",
+        aliases=("claude-code", "claude", "claude-code-cli"),
+        default_config="~/.claude.json",
+        config_format="json",
+    ),
+    AgentMcpConfig(
+        name="cursor",
+        display_name="Cursor",
+        aliases=("cursor",),
+        default_config="~/.cursor/mcp.json",
+        config_format="json",
+    ),
+    AgentMcpConfig(
+        name="antigravity",
+        display_name="Antigravity / Gemini CLI",
+        aliases=("antigravity", "gemini", "gemini-cli"),
+        default_config="~/.gemini/settings.json",
+        config_format="json",
+    ),
+    AgentMcpConfig(
+        name="vscode",
+        display_name="VS Code",
+        aliases=("vscode", "vs-code", "visual-studio-code"),
+        default_config=".vscode/mcp.json",
+        config_format="json",
+        top_key="servers",
+        include_type=True,
+    ),
+    AgentMcpConfig(
+        name="copilot",
+        display_name="GitHub Copilot in VS Code",
+        aliases=("copilot", "github-copilot"),
+        default_config=".vscode/mcp.json",
+        config_format="json",
+        top_key="servers",
+        include_type=True,
+    ),
+)
+
+
+def supported_agents() -> tuple[str, ...]:
+    """Return canonical agent names supported by `link connect`."""
+    return tuple(config.name for config in AGENT_CONFIGS)
+
+
+def _agent_by_name(agent: str) -> AgentMcpConfig:
+    normalized = agent.strip().lower().replace("_", "-")
+    for config in AGENT_CONFIGS:
+        if normalized == config.name or normalized in config.aliases:
+            return config
+    choices = ", ".join(supported_agents())
+    raise ValueError(f"unsupported agent for link connect: {agent}. Try one of: {choices}")
+
+
+def _config_path(default_config: str, override: str | None) -> Path:
+    path = Path(override or default_config).expanduser()
+    if not path.is_absolute():
+        path = (Path.cwd() / path).resolve()
+    return path
+
+
+def _server_config(config: AgentMcpConfig, python_cmd: str, wiki_dir: Path) -> dict[str, object]:
+    server: dict[str, object] = {
+        "command": python_cmd,
+        "args": ["-m", "link_mcp", "--wiki", str(wiki_dir)],
+    }
+    if config.include_type:
+        server["type"] = "stdio"
+    if config.include_disabled:
+        server["disabled"] = False
+    return server
+
+
+def _json_config(config: AgentMcpConfig, python_cmd: str, wiki_dir: Path) -> dict[str, object]:
+    return {
+        config.top_key: {
+            "link": _server_config(config, python_cmd, wiki_dir),
+        }
+    }
+
+
+def _codex_toml_snippet(python_cmd: str, wiki_dir: Path) -> str:
+    return "\n".join([
+        "[mcp_servers.link]",
+        f"command = {json.dumps(python_cmd)}",
+        f'args = ["-m", "link_mcp", "--wiki", {json.dumps(str(wiki_dir))}]',
+    ])
+
+
+def _config_snippet(config: AgentMcpConfig, python_cmd: str, wiki_dir: Path) -> str:
+    if config.config_format == "codex-toml":
+        return _codex_toml_snippet(python_cmd, wiki_dir)
+    return json.dumps(_json_config(config, python_cmd, wiki_dir), indent=2)
+
+
+def _write_json_config(path: Path, config: AgentMcpConfig, python_cmd: str, wiki_dir: Path) -> None:
+    payload: dict[str, Any] = {}
+    if path.exists() and path.read_text(encoding="utf-8", errors="replace").strip():
+        payload = json.loads(path.read_text(encoding="utf-8", errors="replace"))
+        if not isinstance(payload, dict):
+            raise ValueError(f"{path} must contain a JSON object")
+    existing = payload.get(config.top_key)
+    if not isinstance(existing, dict):
+        existing = {}
+    existing["link"] = _server_config(config, python_cmd, wiki_dir)
+    payload[config.top_key] = existing
+    atomic_write_json(path, payload)
+
+
+def _write_codex_config(path: Path, python_cmd: str, wiki_dir: Path) -> None:
+    block = _codex_toml_snippet(python_cmd, wiki_dir) + "\n"
+    text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
+    pattern = re.compile(r"(?ms)^\[mcp_servers\.link\]\r?\n.*?(?=^\[|\Z)")
+    if pattern.search(text):
+        text = pattern.sub(block, text)
+        if not text.endswith("\n"):
+            text += "\n"
+    else:
+        text = text.rstrip() + ("\n\n" if text.strip() else "") + block
+    atomic_write_text(path, text)
+
+
+def _write_config(path: Path, config: AgentMcpConfig, python_cmd: str, wiki_dir: Path) -> None:
+    if config.config_format == "codex-toml":
+        _write_codex_config(path, python_cmd, wiki_dir)
+        return
+    _write_json_config(path, config, python_cmd, wiki_dir)
+
+
+def build_mcp_connect_payload(
+    *,
+    target: Path,
+    wiki_dir: Path,
+    agent: str,
+    expected_version: str,
+    init_command: list[str],
+    python_cmd: str | None = None,
+    default_python: str,
+    config_path: str | None = None,
+    write: bool = False,
+) -> dict[str, object]:
+    """Build or write an MCP client configuration for a supported local agent."""
+    config = _agent_by_name(agent)
+    resolved_python = resolve_mcp_python(target, wiki_dir, python_cmd, default_python=default_python)
+    path = _config_path(config.default_config, config_path)
+    snippet = _config_snippet(config, resolved_python, wiki_dir)
+    write_status: dict[str, object] = {"requested": write, "ok": False, "message": "preview only"}
+    if write:
+        try:
+            _write_config(path, config, resolved_python, wiki_dir)
+            write_status = {"requested": True, "ok": True, "message": f"updated {path}"}
+        except Exception as exc:
+            write_status = {"requested": True, "ok": False, "message": str(exc)}
+
+    connect_command = ["link", "connect", config.name, str(target)]
+    if config_path:
+        connect_command.extend(["--config", str(path)])
+    if python_cmd:
+        connect_command.extend(["--python", resolved_python])
+    connect_command.append("--write")
+
+    return {
+        "agent": config.name,
+        "display_name": config.display_name,
+        "target": str(target),
+        "wiki": str(wiki_dir),
+        "python": resolved_python,
+        "expected_version": expected_version,
+        "config_path": str(path),
+        "config_format": config.config_format,
+        "config": _json_config(config, resolved_python, wiki_dir) if config.config_format == "json" else None,
+        "snippet": snippet,
+        "write": write_status,
+        "next_actions": [
+            {
+                "label": "write config",
+                "command": connect_command,
+                "command_text": display_command(connect_command),
+            },
+            {
+                "label": "verify MCP runtime",
+                "command": ["link", "verify-mcp", str(target), "--python", resolved_python],
+                "command_text": display_command(["link", "verify-mcp", str(target), "--python", resolved_python]),
+            },
+            {
+                "label": "create wiki if missing",
+                "command": init_command,
+                "command_text": display_command(init_command),
+            },
+        ],
+        "restart_hint": config.restart_hint,
+    }
diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py
index efe1da9..6399602 100644
--- a/scripts/check_tool_contract.py
+++ b/scripts/check_tool_contract.py
@@ -16,6 +16,7 @@
     "brief",
     "capture-inbox",
     "capture-session",
+    "connect",
     "delete-capture",
     "demo",
     "doctor",
diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py
index 0f8db63..4f8892b 100644
--- a/tests/test_cli_parser_core.py
+++ b/tests/test_cli_parser_core.py
@@ -61,6 +61,29 @@ def test_health_json_option(self):
         self.assertEqual(args.target, "/tmp/link")
         self.assertTrue(args.json)
 
+    def test_connect_command_options(self):
+        parser = build_cli_parser()
+
+        args = parser.parse_args([
+            "connect",
+            "codex",
+            "/tmp/link",
+            "--write",
+            "--config",
+            "/tmp/config.toml",
+            "--python",
+            "/tmp/python",
+            "--json",
+        ])
+
+        self.assertEqual(args.command, "connect")
+        self.assertEqual(args.agent, "codex")
+        self.assertEqual(args.target, "/tmp/link")
+        self.assertTrue(args.write)
+        self.assertEqual(args.config, "/tmp/config.toml")
+        self.assertEqual(args.python, "/tmp/python")
+        self.assertTrue(args.json)
+
     def test_version_command_routes_to_handler(self):
         parser = build_cli_parser()
 
@@ -166,6 +189,35 @@ def health_handler(target, **kwargs):
         self.assertEqual(calls[0][0], Path("/tmp/link"))
         self.assertTrue(calls[0][1]["json_output"])
 
+    def test_dispatch_routes_connect_arguments(self):
+        parser = build_cli_parser()
+        args = parser.parse_args([
+            "connect",
+            "kiro",
+            "/tmp/link",
+            "--write",
+            "--config",
+            "/tmp/mcp.json",
+            "--python",
+            "/tmp/python",
+            "--json",
+        ])
+        calls = []
+
+        def connect_handler(target, agent, **kwargs):
+            calls.append((target, agent, kwargs))
+            return 4
+
+        code = dispatch_cli_command(args, {"connect": connect_handler})
+
+        self.assertEqual(code, 4)
+        self.assertEqual(calls[0][0], Path("/tmp/link"))
+        self.assertEqual(calls[0][1], "kiro")
+        self.assertTrue(calls[0][2]["write"])
+        self.assertEqual(calls[0][2]["config_path"], "/tmp/mcp.json")
+        self.assertEqual(calls[0][2]["python_cmd"], "/tmp/python")
+        self.assertTrue(calls[0][2]["json_output"])
+
     def test_dispatch_routes_welcome_arguments(self):
         parser = build_cli_parser()
         args = parser.parse_args(["welcome", "/tmp/link", "--project", "alpha", "--json"])
diff --git a/tests/test_cli_runtime_core.py b/tests/test_cli_runtime_core.py
index d4d2794..0341a00 100644
--- a/tests/test_cli_runtime_core.py
+++ b/tests/test_cli_runtime_core.py
@@ -3,6 +3,7 @@
 from mcp_package.link_core.cli_runtime import (
     render_demo_text,
     render_init_text,
+    render_mcp_connect_text,
     render_starter_prompts_text,
     render_try_text,
     render_welcome_text,
@@ -104,6 +105,28 @@ def test_render_try_text(self):
         self.assertIn("Ask an agent:", text)
         self.assertIn("link next /tmp/link-demo", text)
 
+    def test_render_mcp_connect_text_preview(self):
+        code, text = render_mcp_connect_text({
+            "display_name": "Codex",
+            "wiki": "/tmp/link/wiki",
+            "python": "/tmp/python",
+            "config_path": "/tmp/config.toml",
+            "snippet": "[mcp_servers.link]\ncommand = \"/tmp/python\"",
+            "write": {"requested": False, "ok": False, "message": "preview only"},
+            "next_actions": [
+                {"label": "write config", "command_text": "link connect codex /tmp/link --write"},
+                {"label": "verify MCP runtime", "command_text": "link verify-mcp /tmp/link --python /tmp/python"},
+            ],
+            "restart_hint": "Restart the agent, then ask: is Link ready?",
+        })
+
+        self.assertEqual(code, 0)
+        self.assertIn("Link connect: Codex", text)
+        self.assertIn("Preview only", text)
+        self.assertIn("link connect codex /tmp/link --write", text)
+        self.assertIn("[mcp_servers.link]", text)
+        self.assertIn("link verify-mcp /tmp/link --python /tmp/python", text)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_mcp_connect_core.py b/tests/test_mcp_connect_core.py
new file mode 100644
index 0000000..6f644d5
--- /dev/null
+++ b/tests/test_mcp_connect_core.py
@@ -0,0 +1,137 @@
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "mcp_package"))
+
+from link_core.mcp_connect import build_mcp_connect_payload, supported_agents  # noqa: E402
+
+
+class McpConnectCoreTests(unittest.TestCase):
+    def test_supported_agents_include_primary_install_targets(self):
+        agents = supported_agents()
+
+        for agent in ("codex", "kiro", "claude-code", "cursor", "antigravity", "vscode", "copilot"):
+            self.assertIn(agent, agents)
+
+    def test_build_codex_preview_uses_marker_python(self):
+        with tempfile.TemporaryDirectory() as temp:
+            root = Path(temp)
+            wiki = root / "wiki"
+            wiki.mkdir()
+            (root / ".link-mcp-python").write_text("/tmp/link python/bin/python\n", encoding="utf-8")
+
+            payload = build_mcp_connect_payload(
+                target=root,
+                wiki_dir=wiki,
+                agent="codex",
+                expected_version="1.3.0",
+                init_command=["link", "init", str(root)],
+                default_python="python3",
+            )
+
+        self.assertEqual(payload["agent"], "codex")
+        self.assertEqual(payload["python"], "/tmp/link python/bin/python")
+        self.assertIn("[mcp_servers.link]", str(payload["snippet"]))
+        self.assertIn(str(wiki), str(payload["snippet"]))
+
+    def test_write_codex_config_replaces_existing_link_block(self):
+        with tempfile.TemporaryDirectory() as temp:
+            root = Path(temp)
+            wiki = root / "wiki"
+            wiki.mkdir()
+            config = root / "config.toml"
+            config.write_text("[mcp_servers.link]\ncommand = \"old\"\n\n[ui]\ntheme = \"dark\"\n", encoding="utf-8")
+
+            payload = build_mcp_connect_payload(
+                target=root,
+                wiki_dir=wiki,
+                agent="codex",
+                expected_version="1.3.0",
+                init_command=["link", "init", str(root)],
+                python_cmd="/tmp/python",
+                default_python="python3",
+                config_path=str(config),
+                write=True,
+            )
+
+            text = config.read_text(encoding="utf-8")
+
+        self.assertTrue(payload["write"]["ok"])
+        self.assertIn('command = "/tmp/python"', text)
+        self.assertIn("[ui]", text)
+        self.assertNotIn('command = "old"', text)
+
+    def test_write_json_config_preserves_existing_keys(self):
+        with tempfile.TemporaryDirectory() as temp:
+            root = Path(temp)
+            wiki = root / "wiki"
+            wiki.mkdir()
+            config = root / "mcp.json"
+            config.write_text(json.dumps({"mcpServers": {"other": {"command": "x"}}}), encoding="utf-8")
+
+            payload = build_mcp_connect_payload(
+                target=root,
+                wiki_dir=wiki,
+                agent="kiro",
+                expected_version="1.3.0",
+                init_command=["link", "init", str(root)],
+                python_cmd="/tmp/python",
+                default_python="python3",
+                config_path=str(config),
+                write=True,
+            )
+            data = json.loads(config.read_text(encoding="utf-8"))
+
+        self.assertTrue(payload["write"]["ok"])
+        self.assertEqual(data["mcpServers"]["other"]["command"], "x")
+        self.assertEqual(data["mcpServers"]["link"]["command"], "/tmp/python")
+        self.assertFalse(data["mcpServers"]["link"]["disabled"])
+
+    def test_vscode_uses_servers_top_key_and_stdio_type(self):
+        with tempfile.TemporaryDirectory() as temp:
+            root = Path(temp)
+            wiki = root / "wiki"
+            wiki.mkdir()
+            config = root / "mcp.json"
+
+            payload = build_mcp_connect_payload(
+                target=root,
+                wiki_dir=wiki,
+                agent="vscode",
+                expected_version="1.3.0",
+                init_command=["link", "init", str(root)],
+                python_cmd="/tmp/python",
+                default_python="python3",
+                config_path=str(config),
+                write=True,
+            )
+            data = json.loads(config.read_text(encoding="utf-8"))
+
+        self.assertTrue(payload["write"]["ok"])
+        self.assertEqual(data["servers"]["link"]["type"], "stdio")
+        self.assertEqual(data["servers"]["link"]["args"], ["-m", "link_mcp", "--wiki", str(wiki)])
+
+    def test_unknown_agent_is_clear(self):
+        with tempfile.TemporaryDirectory() as temp:
+            root = Path(temp)
+            wiki = root / "wiki"
+            wiki.mkdir()
+
+            with self.assertRaisesRegex(ValueError, "unsupported agent"):
+                build_mcp_connect_payload(
+                    target=root,
+                    wiki_dir=wiki,
+                    agent="not-real",
+                    expected_version="1.3.0",
+                    init_command=["link", "init", str(root)],
+                    default_python="python3",
+                )
+
+
+if __name__ == "__main__":
+    unittest.main()

From ccb86b3dfa33143a3212c4b81ab8e2881743b644 Mon Sep 17 00:00:00 2001
From: Gowtham 
Date: Mon, 25 May 2026 20:36:14 -0600
Subject: [PATCH 08/35] Add before-after proof loop to docs

---
 docs/assets/site.css | 33 +++++++++++++++++++++++++++++++++
 docs/index.html      | 32 ++++++++++++++++++++++++++++++++
 docs/why-link.html   | 11 +++++++++++
 3 files changed, 76 insertions(+)

diff --git a/docs/assets/site.css b/docs/assets/site.css
index 41790b9..43f9272 100644
--- a/docs/assets/site.css
+++ b/docs/assets/site.css
@@ -289,6 +289,38 @@ section:nth-child(2n),
   margin-top: 34px;
 }
 
+.proof-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 22px;
+  margin-top: 34px;
+}
+
+.proof-column {
+  padding: 22px;
+  border: 3px solid var(--border);
+  background: var(--paper-2);
+  box-shadow: var(--shadow);
+}
+
+.proof-column.before { background: #ffe1dc; }
+.proof-column.after { background: #dff8ef; }
+
+.proof-column ol {
+  margin: 14px 0 20px;
+  padding-left: 22px;
+}
+
+.proof-column li {
+  margin: 7px 0;
+  font-weight: 650;
+}
+
+.proof-column pre {
+  box-shadow: none;
+  font-size: 13px;
+}
+
 .screenshot-row {
   display: grid;
   grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -571,6 +603,7 @@ footer a {
   .grid,
   .grid.two-up,
   .flow,
+  .proof-grid,
   .screenshot-row,
   .media-grid,
   .compare,
diff --git a/docs/index.html b/docs/index.html
index d308f25..3560c4c 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -82,6 +82,38 @@ 

A local memory pipeline agents can trust.

+
+
+

Before and after

+

The useful moment is not storage. It is continuity.

+

Link is built for the daily handoff between sessions and agents: stop re-explaining the same context, start from a brief that has provenance.

+
+
+

Without Link

+
    +
  1. Open a new agent session.
  2. +
  3. Explain your project, preferences, and recent decisions again.
  4. +
  5. Paste notes or ask the agent to scan a folder.
  6. +
  7. Lose the context when you switch tools.
  8. +
+
User: we talked about this yesterday...
+Agent: I do not have that context.
+
+
+

With Link

+
    +
  1. Ask: brief me from Link before we continue.
  2. +
  3. The agent gets reviewed memory plus source-backed wiki context.
  4. +
  5. Decisions can be remembered, reviewed, archived, or forgotten.
  6. +
  7. Another MCP agent can recall the same local memory.
  8. +
+
User: brief me from Link before we continue
+Agent: Here is the relevant memory, sources, and next safe action.
+
+
+
+
+
diff --git a/docs/why-link.html b/docs/why-link.html index 36b9b09..8a38a74 100644 --- a/docs/why-link.html +++ b/docs/why-link.html @@ -45,6 +45,7 @@

Link is not a notes app. It is local memory for agents.

+

The Loop Link Changes

+

Link is useful when it changes the start and end of real agent sessions.

+
+
MomentWithout LinkWith Link
+
Starting workYou retype project state and preferences.The agent calls memory_brief or query_link for compact local context.
+
Switching agentsEach tool has its own memory gap.Codex, Claude, Cursor, Kiro, VS Code, and Antigravity can point at the same wiki.
+
Saving a decisionThe note disappears into chat history.An explicit remember becomes a reviewable Markdown memory.
+
Trusting memoryYou cannot see why the agent knows something.You can inspect sources, graph links, review state, and lifecycle history.
+
+

How To Choose

If you need...Use Link when...Use another category when...
From 903f2cfe2d54a9a527b7dfe9abdfbeae8ed1e82e Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 21:04:54 -0600 Subject: [PATCH 09/35] Add scheduled memory review dates --- CHANGELOG.md | 1 + LINK.md | 1 + README.md | 6 +++- docs/cli.html | 4 +-- docs/concepts.html | 3 +- link.py | 5 ++- mcp_package/README.md | 5 +-- mcp_package/link_core/cli_memory.py | 2 ++ mcp_package/link_core/cli_parser.py | 2 ++ mcp_package/link_core/memory.py | 47 +++++++++++++++++++++++++++-- mcp_package/link_core/validation.py | 12 ++++++++ mcp_package/link_core/web_memory.py | 2 ++ mcp_package/link_mcp/server.py | 11 +++++-- serve.py | 1 + tests/test_cli_memory_core.py | 2 ++ tests/test_cli_parser_core.py | 12 +++++++- tests/test_memory_core.py | 41 +++++++++++++++++++++++++ tests/test_validation_core.py | 27 +++++++++++++++++ 18 files changed, 171 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd9fd6a..6c0438b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link try` as a one-command demo proof loop that creates the demo, checks readiness, runs query/brief examples, and prints first agent prompts. - Added `link connect ` to preview or write MCP client config for Codex, Kiro, Claude Code, Cursor, Antigravity, VS Code, and Copilot. +- Added optional `review_after` dates for durable memories so time-sensitive context can automatically return to the memory inbox for re-checking. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/LINK.md b/LINK.md index e2f6106..69d7f87 100644 --- a/LINK.md +++ b/LINK.md @@ -178,6 +178,7 @@ update_count: 0 source: "manual | conversation | mcp | raw/source.md" last_update_source: "" review_status: pending | reviewed | needs_update +review_after: "optional YYYY-MM-DD date for scheduled re-check" tags: [memory, relevant-tag] --- diff --git a/README.md b/README.md index 9408dae..8f454fb 100644 --- a/README.md +++ b/README.md @@ -309,10 +309,14 @@ creating a duplicate. - `ingest_status`: exact next steps for raw files, including source safety, stale ingest detection, validation, and memory proposal guidance. - `remember_memory`: durable local memory with duplicate/conflict checks, - review state, provenance, and audit logging. + review state, optional `review_after` re-check dates, provenance, and audit logging. - `explain_memory`: why a memory exists, what it links to, whether it is ready for recall, and what needs review. +Use `review_after` for time-sensitive preferences or decisions. When that date +arrives, the memory reappears in Link's review inbox so an agent can ask the +user to confirm, update, archive, or forget it instead of trusting stale context. + ## Agent Contract Agents should use Link in this order: diff --git a/docs/cli.html b/docs/cli.html index 8058392..6986726 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -66,7 +66,7 @@

Daily Loop

link next link health link ingest-status -link remember "User prefers feature branches for Link work." --type preference --scope project --project link +link remember "User prefers feature branches for Link work." --type preference --scope project --project link --review-after 2026-08-01 link brief "working on Link release" --project link link query "what should I know before changing the MCP tools?" --budget small --project link link validate
@@ -124,7 +124,7 @@

All Commands

link operations [--limit 20] link backup [--label name] [--include-raw] link ingest-status -link remember "text" [--project slug] +link remember "text" [--project slug] [--review-after YYYY-MM-DD] link propose-memories <file-or-text> [--project slug] link capture-session <file-or-text> [--project slug] link capture-inbox [--project slug] diff --git a/docs/concepts.html b/docs/concepts.html index 6889514..d880b83 100644 --- a/docs/concepts.html +++ b/docs/concepts.html @@ -84,11 +84,12 @@

Three User Moves

Raw files do not silently personalize future agents. Ingest creates source-backed wiki knowledge. Explicit remember creates durable user or project memory.

Memory Lifecycle

-

A memory is a Markdown page with status, scope, source, review state, graph links, and local log entries. It can be proposed, remembered, reviewed, updated, archived, restored, explained, or forgotten.

+

A memory is a Markdown page with status, scope, source, review state, optional review_after date, graph links, and local log entries. It can be proposed, remembered, reviewed, updated, archived, restored, explained, or forgotten.

Propose

Generate candidate memories from chat notes or raw captures without writing durable memory.

Approve

Save only the memories the user explicitly wants agents to carry forward.

Explain

Show why a memory exists, whether it is recall-ready, and what graph links support it.

+

Re-check

Use review_after when a memory should come back to the inbox after a date instead of staying trusted forever.

Smart Query Packets

diff --git a/link.py b/link.py index 2d80a80..565816e 100644 --- a/link.py +++ b/link.py @@ -543,14 +543,15 @@ def _write_memory_page( tags: str | None = None, source: str = "manual", timestamp: str | None = None, allow_duplicate: bool = False, allow_conflict: bool = False, project: str | None = None, + review_after: str | None = None, ) -> dict[str, object]: wiki_dir, records = _memory_runtime(target) clean_text = _required_memory_text(text, "memory text required") options = _memory_mutation_options(wiki_dir, records, timestamp, project) - return _core_write_memory_page( wiki_dir, clean_text, title=title, memory_type=memory_type, scope=scope, tags=tags, source=source, + review_after=review_after, allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, **options, ) @@ -803,6 +804,7 @@ def remember( allow_duplicate: bool = False, allow_conflict: bool = False, project: str | None = None, + review_after: str | None = None, json_output: bool = False, ) -> int: if not text or not text.strip(): @@ -820,6 +822,7 @@ def remember( allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, project=project or _default_project(target), + review_after=review_after, ) except (FileNotFoundError, ValueError) as exc: print(f"Could not remember: {exc}", file=sys.stderr) diff --git a/mcp_package/README.md b/mcp_package/README.md index a35cb43..eb2e27a 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -105,7 +105,8 @@ Most agents should call: 7. `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. +memory. Add `review_after` for memories that should return to the review inbox +after a date. Use `propose_memories` or `capture_session` for proposal-only review. For local CLI setup checks, `link verify-mcp --json` returns structured `issues` and `next_actions` that agents and scripts can consume without parsing terminal text. @@ -143,7 +144,7 @@ In the local web proposal picker, unreadable raw files are surfaced as | `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?, 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. | +| `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?, project?, review_after?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. `review_after` accepts `YYYY-MM-DD` for scheduled re-checks. | | `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. | | `capture_inbox(limit?, project?)` | Review saved raw captures with redacted snippets, secret-warning labels, and accept/redact/delete commands. | diff --git a/mcp_package/link_core/cli_memory.py b/mcp_package/link_core/cli_memory.py index 6f71660..2f0b2d8 100644 --- a/mcp_package/link_core/cli_memory.py +++ b/mcp_package/link_core/cli_memory.py @@ -96,6 +96,8 @@ def render_remember_text(result: Mapping[str, object], *, target: object = ".") ] if result.get("project"): lines.append(f"Project: {result['project']}") + if result.get("review_after"): + lines.append(f"Review after: {result['review_after']}") lines.extend([ "", "Next:", diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index dc6eb66..5764406 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -97,6 +97,7 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum 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("--review-after", default=None, help="YYYY-MM-DD date when this memory should be checked again") 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") @@ -317,6 +318,7 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: tags=args.tags, source=args.source, project=args.project, + review_after=args.review_after, allow_duplicate=args.allow_duplicate, allow_conflict=args.allow_conflict, json_output=args.json, diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 0acf24e..cba9994 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -7,6 +7,7 @@ import subprocess import urllib.parse from collections.abc import Callable, Iterable, Mapping +from datetime import date from pathlib import Path from .files import atomic_write_text @@ -101,6 +102,7 @@ } MemoryLogWriter = Callable[[str, str, str, list[str]], None] BacklinkRebuilder = Callable[[], bool] +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") def slugify(value: str, fallback: str = "memory") -> str: @@ -196,6 +198,19 @@ def is_active_memory(record: Mapping[str, object]) -> bool: return str(record.get("status") or "active").lower() not in {"archived", "stale"} +def _parse_review_date(value: object) -> date | None: + text = str(value or "").strip().strip('"') + if not text: + return None + if not DATE_RE.match(text): + raise ValueError("review_after must use YYYY-MM-DD") + return date.fromisoformat(text) + + +def _today(today: str | None = None) -> date: + return _parse_review_date(today) if today else date.today() + + def memory_visible_for_project(record: Mapping[str, object], project: str | None = None) -> bool: project_name = normalize_project(project) if not project_name: @@ -248,6 +263,7 @@ def memory_record_from_page(wiki_dir: Path, path: Path, include_body: bool = Tru "source": meta.get("source", ""), "review_status": meta.get("review_status") or "pending", "reviewed_at": meta.get("reviewed_at", ""), + "review_after": meta.get("review_after", ""), "review_note": meta.get("review_note", ""), "tags": meta_tags(meta.get("tags", "")), "tldr": extract_tldr(body), @@ -273,6 +289,7 @@ def memory_records(wiki_dir: Path, include_body: bool = True) -> list[dict[str, def memory_review_issues( record: Mapping[str, object], review_command: str = "review-memory", + today: str | None = None, ) -> list[dict[str, str]]: issues: list[dict[str, str]] = [] status = str(record.get("status") or "active").lower() @@ -301,6 +318,25 @@ def memory_review_issues( "message": f"Unknown review_status: {review_status}.", "suggested_action": "Use pending, reviewed, or needs_update.", }) + review_after = str(record.get("review_after") or "").strip() + if review_after: + try: + due = _parse_review_date(review_after) + except ValueError as exc: + issues.append({ + "code": "invalid_review_after", + "severity": "high", + "message": str(exc), + "suggested_action": "Use a YYYY-MM-DD date or remove review_after.", + }) + else: + if status == "active" and due is not None and due <= _today(today): + issues.append({ + "code": "review_due", + "severity": "medium", + "message": f"Memory review is due after {review_after}.", + "suggested_action": f"Confirm it is still accurate, then run {review_command}.", + }) if status == "stale": issues.append({ @@ -430,7 +466,7 @@ def add(action: dict[str, object]) -> None: )) return actions - if issue_codes & {"invalid_review_status", "invalid_memory_type", "invalid_scope", "missing_source", "missing_date_captured"}: + if issue_codes & {"invalid_review_status", "invalid_review_after", "invalid_memory_type", "invalid_scope", "missing_source", "missing_date_captured"}: add(_memory_action( kind="edit_metadata", label="Edit metadata", @@ -460,7 +496,7 @@ def add(action: dict[str, object]) -> None: arguments={"identifier": name, "reason": "stale"}, priority="high", )) - if "pending_review" in issue_codes and not any( + if issue_codes & {"pending_review", "review_due"} and not any( issue.get("severity") == "high" for issue in issue_list ): add(_memory_action( @@ -1084,6 +1120,7 @@ def write_memory_page( source: str, timestamp: str, project: str | None = None, + review_after: str | None = None, records: Iterable[Mapping[str, object]] | None = None, allow_duplicate: bool = False, allow_conflict: bool = False, @@ -1099,6 +1136,9 @@ def write_memory_page( if not clean_text: raise ValueError("memory text required") clean_source = source.strip() if source is not None else "" + clean_review_after = str(review_after or "").strip() + if clean_review_after: + _parse_review_date(clean_review_after) clean_project = normalize_project(project) if scope == "project" else "" memory_title_value = memory_title(clean_text, title) summary = clean_text.splitlines()[0].strip() @@ -1154,6 +1194,7 @@ def write_memory_page( 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 "" + review_after_line = f'review_after: "{frontmatter_string(clean_review_after)}"\n' if clean_review_after else "" page = f"""--- type: memory @@ -1164,6 +1205,7 @@ def write_memory_page( date_captured: "{timestamp}" source: "{frontmatter_string(clean_source)}" review_status: pending +{review_after_line}reviewed_at: "" tags: {yaml_list(tag_values)} --- @@ -1213,6 +1255,7 @@ def write_memory_page( "memory_type": memory_type, "scope": scope, "project": clean_project, + "review_after": clean_review_after, "backlinks_rebuilt": bool(backlinks_rebuilt), "duplicate_override": bool(duplicate_candidates and allow_duplicate), "duplicate_candidates": duplicate_candidates, diff --git a/mcp_package/link_core/validation.py b/mcp_package/link_core/validation.py index bcf9b04..ffed088 100644 --- a/mcp_package/link_core/validation.py +++ b/mcp_package/link_core/validation.py @@ -2,6 +2,7 @@ from __future__ import annotations import re +from datetime import date from pathlib import Path from typing import Any @@ -37,6 +38,7 @@ } SUMMARY_RE = re.compile(r">\s*\*\*(?:TLDR|Query):\*\*", re.IGNORECASE) +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") def _finding(severity: str, code: str, path: str, message: str) -> dict[str, str]: @@ -163,6 +165,16 @@ def validate_wiki(wiki_dir: Path, *, strict: bool = False) -> dict[str, Any]: if not str(meta.get(field) or "").strip(): findings.append(_finding("error", "missing_frontmatter_field", rel, f"Missing required frontmatter field: {field}")) + review_after = str(meta.get("review_after") or "").strip().strip('"') + if expected_type == "memory" and review_after: + if not DATE_RE.match(review_after): + findings.append(_finding("error", "invalid_review_after", rel, "review_after must use YYYY-MM-DD.")) + else: + try: + date.fromisoformat(review_after) + except ValueError: + findings.append(_finding("error", "invalid_review_after", rel, "review_after must be a valid calendar date.")) + if not SUMMARY_RE.search(body): findings.append(_finding("warning", "missing_summary", rel, "Page should include a TLDR or Query summary.")) diff --git a/mcp_package/link_core/web_memory.py b/mcp_package/link_core/web_memory.py index 582655f..3ea629c 100644 --- a/mcp_package/link_core/web_memory.py +++ b/mcp_package/link_core/web_memory.py @@ -146,6 +146,8 @@ def render_memory_card( meta_parts.append(f'updated {record["updated_at"]}') elif record.get("date_captured"): meta_parts.append(f'captured {record["date_captured"]}') + if record.get("review_after"): + meta_parts.append(f'review after {record["review_after"]}') meta = " · ".join(part for part in meta_parts if part) issues_html = "" if include_issues and record.get("issues"): diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index d8980aa..54cc479 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -577,7 +577,7 @@ def _accept_capture( scope=_clean_text_input(scope).lower(), tags=tags, ) - result = _write_memory_page( + result = _write_mcp_memory_page( str(memory_args["text"]), title=str(memory_args["title"]), memory_type=str(memory_args["memory_type"]), @@ -739,10 +739,11 @@ def _update_memory_page( return result -def _write_memory_page( +def _write_mcp_memory_page( text: str, title: str = "", memory_type: str = "note", scope: str = "user", tags: str = "", source: str = "mcp", allow_duplicate: bool = False, allow_conflict: bool = False, project: str = "", + review_after: str = "", ) -> dict[str, object]: clean_text = _required_text_input(text, "memory text required", max_len=4000) memory_type, scope = _memory_type_scope(memory_type, scope) @@ -752,6 +753,7 @@ def _write_memory_page( WIKI_DIR, clean_text, title=_clean_text_input(title), memory_type=memory_type, scope=scope, tags=_clean_text_input(tags, max_len=500), source=_clean_text_input(source, max_len=500), + review_after=_clean_text_input(review_after, max_len=40) or None, allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, **options, ) @@ -1170,6 +1172,7 @@ def remember_memory( allow_duplicate: bool = False, allow_conflict: bool = False, project: str = "", + review_after: str = "", ) -> str: """Save a local agent memory as a Markdown page. @@ -1181,9 +1184,10 @@ def remember_memory( scope: user, project, or global. project: optional project key for project-scoped memories. tags: optional comma-separated tags. + review_after: optional YYYY-MM-DD date when this memory should be checked again. """ try: - result = _write_memory_page( + result = _write_mcp_memory_page( memory, title=title, memory_type=memory_type, @@ -1193,6 +1197,7 @@ def remember_memory( allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, project=project, + review_after=review_after, ) except ValueError as exc: return json.dumps({"created": False, "error": str(exc)}) diff --git a/serve.py b/serve.py index 8aef0ae..67def36 100644 --- a/serve.py +++ b/serve.py @@ -563,6 +563,7 @@ def _remember_memory_from_web(payload: dict[str, object]) -> dict[str, object]: _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, + review_after=_clean_text_input(payload.get("review_after"), max_len=40) or None, records=_memory_records(), allow_duplicate=False, allow_conflict=False, diff --git a/tests/test_cli_memory_core.py b/tests/test_cli_memory_core.py index 2a692b4..b333525 100644 --- a/tests/test_cli_memory_core.py +++ b/tests/test_cli_memory_core.py @@ -25,11 +25,13 @@ def test_render_remember_created(self): "memory_type": "preference", "scope": "project", "project": "link", + "review_after": "2026-08-01", }) self.assertEqual(code, 0) self.assertIn("Memory saved", text) self.assertIn("Project: link", text) + self.assertIn("Review after: 2026-08-01", text) self.assertIn("python3 link.py recall", text) self.assertIn("Prefer release branches", text) diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index 4f8892b..75a5603 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -116,10 +116,20 @@ def test_next_alias_routes_to_prompts(self): def test_memory_choices_are_enforced(self): parser = build_cli_parser() - args = parser.parse_args(["remember", "prefers concise answers", "--type", "preference", "--scope", "user"]) + args = parser.parse_args([ + "remember", + "prefers concise answers", + "--type", + "preference", + "--scope", + "user", + "--review-after", + "2026-06-01", + ]) self.assertEqual(args.memory_type, "preference") self.assertEqual(args.scope, "user") + self.assertEqual(args.review_after, "2026-06-01") with self.assertRaises(SystemExit): parser.parse_args(["remember", "bad", "--type", "unsupported"]) diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index c9fc0aa..5283d29 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -22,6 +22,7 @@ memory_inbox, memory_log_entries, memory_profile, + memory_review_issues, memory_records, propose_memories_from_text, recall_memories, @@ -111,6 +112,43 @@ def test_memory_records_profile_and_recall(self): self.assertEqual(recalled[0]["highest_review_severity"], "none") self.assertNotIn("body", recalled[0]) + def test_review_after_marks_memory_due(self): + record = { + "name": "review-me", + "memory_type": "preference", + "scope": "user", + "status": "active", + "date_captured": "2026-05-01T00:00:00Z", + "source": "unit test", + "review_status": "reviewed", + "review_after": "2026-05-20", + "tldr": "Review me later.", + } + + issues = memory_review_issues(record, today="2026-05-25") + inbox = memory_inbox([record]) + + self.assertIn("review_due", [issue["code"] for issue in issues]) + self.assertEqual(inbox["review_count"], 1) + self.assertEqual(inbox["items"][0]["primary_action"]["kind"], "review") + + def test_review_after_rejects_invalid_dates(self): + record = { + "name": "bad-review-date", + "memory_type": "preference", + "scope": "user", + "status": "active", + "date_captured": "2026-05-01T00:00:00Z", + "source": "unit test", + "review_status": "reviewed", + "review_after": "tomorrow", + "tldr": "Invalid review date.", + } + + issues = memory_review_issues(record) + + self.assertIn("invalid_review_after", [issue["code"] for issue in issues]) + def test_memory_inbox_returns_action_plan(self): records = [ { @@ -858,6 +896,7 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str tags="git, release", source="unit test", timestamp="2026-05-05T06:00:00Z", + review_after="2026-08-01", records=[], log_writer=log_writer, rebuild_backlinks=lambda: rebuilds.append(True) or True, @@ -872,7 +911,9 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str self.assertEqual(rebuilds, [True]) self.assertIn('title: "Prefer release branches"', memory_text) self.assertIn("memory_type: preference", memory_text) + self.assertIn('review_after: "2026-08-01"', memory_text) self.assertIn("tags: [memory, preference, git, release]", memory_text) + self.assertEqual(created["review_after"], "2026-08-01") self.assertIn("## Source\n\nunit test", memory_text) self.assertIn("[[prefer-release-branches]]", index_text) self.assertEqual(logged[-1][1], "remember") diff --git a/tests/test_validation_core.py b/tests/test_validation_core.py index bae8dad..9ce79a0 100644 --- a/tests/test_validation_core.py +++ b/tests/test_validation_core.py @@ -97,6 +97,33 @@ def test_validate_wiki_rejects_malformed_agent_pages(self): self.assertIn("dead_wikilink", codes) self.assertIn("stale_backlinks", codes) + def test_validate_wiki_rejects_invalid_memory_review_after(self): + wiki = self.make_wiki() + write_page( + wiki, + "memories/bad-review-date.md", + "---\n" + "type: memory\n" + "title: Bad Review Date\n" + "memory_type: preference\n" + "scope: user\n" + "status: active\n" + "source: unit test\n" + "review_status: reviewed\n" + "review_after: tomorrow\n" + "---\n\n" + "# Bad Review Date\n\n" + "> **TLDR:** A memory with a bad review date.\n\n" + "## Memory\n\nReview later.\n\n" + "## Source\n\nunit test\n", + ) + (wiki / "_backlinks.json").write_text(json.dumps(build_backlinks(wiki, body_only=False)), encoding="utf-8") + + payload = validate_wiki(wiki) + + self.assertFalse(payload["passed"]) + self.assertIn("invalid_review_after", {finding["code"] for finding in payload["findings"]}) + def test_validate_wiki_reports_unreadable_pages(self): wiki = self.make_wiki() write_page( From 13e44b5e72e08d15fc49e1a2851e2baa181d49a6 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 21:46:59 -0600 Subject: [PATCH 10/35] Add Obsidian vault import --- CHANGELOG.md | 1 + README.md | 13 ++- docs/cli.html | 1 + docs/obsidian.html | 10 +- link.py | 31 ++++++ mcp_package/link_core/cli_parser.py | 17 +++ mcp_package/link_core/obsidian.py | 166 ++++++++++++++++++++++++++++ scripts/check_tool_contract.py | 1 + tests/test_cli_parser_core.py | 50 +++++++++ tests/test_link_cli.py | 16 +++ tests/test_obsidian_core.py | 65 +++++++++++ 11 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 mcp_package/link_core/obsidian.py create mode 100644 tests/test_obsidian_core.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0438b..3e50520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link try` as a one-command demo proof loop that creates the demo, checks readiness, runs query/brief examples, and prints first agent prompts. - Added `link connect ` to preview or write MCP client config for Codex, Kiro, Claude Code, Cursor, Antigravity, VS Code, and Copilot. - Added optional `review_after` dates for durable memories so time-sensitive context can automatically return to the memory inbox for re-checking. +- Added `link import-obsidian ` to copy Obsidian Markdown notes into `raw/obsidian/` with secret scanning before the normal ingest workflow. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/README.md b/README.md index 8f454fb..5d05364 100644 --- a/README.md +++ b/README.md @@ -271,9 +271,16 @@ python3 -m venv ~/.link-mcp-venv Full setup: [MCP guide](https://gowtham0992.github.io/link/mcp.html). -Obsidian users can open `~/link/wiki` directly as a vault. See the -[Obsidian guide](https://gowtham0992.github.io/link/obsidian.html) for the safe -edit and validation loop. +Obsidian users can import an existing vault into `raw/` for agent ingest, or +open `~/link/wiki` directly as a vault for editing Link pages: + +```bash +link init ~/link +link import-obsidian ~/Documents/ObsidianVault ~/link +``` + +See the [Obsidian guide](https://gowtham0992.github.io/link/obsidian.html) for +the import, edit, and validation loop. ## Storage Model diff --git a/docs/cli.html b/docs/cli.html index 6986726..df6ad53 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -124,6 +124,7 @@

All Commands

link operations [--limit 20] link backup [--label name] [--include-raw] link ingest-status +link import-obsidian <vault> [dir] [--dry-run] [--overwrite] link remember "text" [--project slug] [--review-after YYYY-MM-DD] link propose-memories <file-or-text> [--project slug] link capture-session <file-or-text> [--project slug] diff --git a/docs/obsidian.html b/docs/obsidian.html index 4792f89..2caae33 100644 --- a/docs/obsidian.html +++ b/docs/obsidian.html @@ -44,11 +44,19 @@

Open Link in Obsidian when you want a richer notes UI.

+

Import An Existing Obsidian Vault

+

Use link import-obsidian when you already have project notes in Obsidian and want Link agents to ingest them as source-backed knowledge. Link copies Markdown notes into raw/obsidian/<vault>/, skips Obsidian plugin state, and blocks notes with secret-looking values before writing them.

+
link init ~/link
+link import-obsidian ~/Documents/ObsidianVault ~/link
+link ingest-status ~/link
+

The import does not silently create durable memories. It stages raw source notes, then the normal ingest and proposal workflow decides what becomes wiki knowledge or reviewed memory.

+

Open The Link Wiki

In Obsidian, choose Open folder as vault and select your Link wiki folder:

~/link/wiki
diff --git a/link.py b/link.py index 565816e..87bb0e7 100644 --- a/link.py +++ b/link.py @@ -16,6 +16,7 @@ python link.py migrate [target] python link.py validate [target] python link.py ingest-status [target] + python link.py import-obsidian [target] python link.py remember "memory text" [target] python link.py propose-memories [target] python link.py capture-inbox [target] @@ -206,6 +207,10 @@ from link_core.mcp_connect import ( build_mcp_connect_payload as _core_build_mcp_connect_payload, ) +from link_core.obsidian import ( + import_obsidian_vault as _core_import_obsidian_vault, + render_import_obsidian_text as _core_render_import_obsidian_text, +) from link_core.operations import ( operation_report as _core_operation_report, render_operations_text as _core_render_operations_text, @@ -755,6 +760,31 @@ def ingest_status(target: Path, json_output: bool = False) -> int: return 0 if status["has_raw_dir"] and status["has_wiki_dir"] else 1 +def import_obsidian( + target: Path, + vault: Path, + overwrite: bool = False, + dry_run: bool = False, + limit: int | None = None, + json_output: bool = False, +) -> int: + try: + payload = _core_import_obsidian_vault( + target, + vault, + overwrite=overwrite, + dry_run=dry_run, + limit=limit, + ) + except ValueError as exc: + if json_output: + print(json.dumps({"status": "error", "error": str(exc)}, indent=2)) + else: + print(f"Could not import Obsidian vault: {exc}", file=sys.stderr) + return 1 + return _emit_json_or_text(payload, json_output, _core_render_import_obsidian_text) + + def rebuild_backlinks(target: Path) -> int: wiki_dir = _resolve_wiki_dir(target) if not wiki_dir.exists(): @@ -1807,6 +1837,7 @@ def main(argv: list[str] | None = None) -> int: "migrate": migrate, "validate": validate, "ingest-status": ingest_status, + "import-obsidian": import_obsidian, "remember": remember, "propose-memories": propose_memories, "capture-session": capture_session, diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index 5764406..cf3a593 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -88,6 +88,14 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum ingest_status_cmd.add_argument("target", nargs="?", default=".") ingest_status_cmd.add_argument("--json", action="store_true", help="print machine-readable status") + obsidian_cmd = sub.add_parser("import-obsidian", help="copy Obsidian Markdown notes into raw/ for Link ingest") + obsidian_cmd.add_argument("vault", help="path to the Obsidian vault folder") + obsidian_cmd.add_argument("target", nargs="?", default=".") + obsidian_cmd.add_argument("--overwrite", action="store_true", help="replace previously imported raw notes") + obsidian_cmd.add_argument("--dry-run", action="store_true", help="show what would be imported without writing files") + obsidian_cmd.add_argument("--limit", type=int, default=None, help="maximum notes to scan/import") + obsidian_cmd.add_argument("--json", action="store_true", help="print machine-readable import 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=".") @@ -308,6 +316,15 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: return handlers["validate"](Path(args.target), strict=args.strict, json_output=args.json) if command == "ingest-status": return handlers["ingest-status"](Path(args.target), json_output=args.json) + if command == "import-obsidian": + return handlers["import-obsidian"]( + Path(args.target), + Path(args.vault), + overwrite=args.overwrite, + dry_run=args.dry_run, + limit=args.limit, + json_output=args.json, + ) if command == "remember": return handlers["remember"]( Path(args.target), diff --git a/mcp_package/link_core/obsidian.py b/mcp_package/link_core/obsidian.py new file mode 100644 index 0000000..7011dcb --- /dev/null +++ b/mcp_package/link_core/obsidian.py @@ -0,0 +1,166 @@ +"""Obsidian vault import helpers for Link.""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from .files import atomic_write_bytes +from .security import secret_value_warnings + + +DEFAULT_MAX_FILE_BYTES = 512 * 1024 +SKIP_DIR_NAMES = {".git", ".obsidian", ".trash", "__pycache__", "node_modules"} +OBSIDIAN_SUFFIXES = {".md", ".markdown"} + + +def _slugify(value: str, fallback: str = "vault") -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or fallback + + +def _is_hidden_or_skipped(path: Path, vault: Path) -> bool: + try: + rel = path.relative_to(vault) + except ValueError: + return True + return any(part.startswith(".") or part in SKIP_DIR_NAMES for part in rel.parts[:-1]) + + +def _limited(items: list[dict[str, object]], limit: int = 20) -> list[dict[str, object]]: + return items[:max(0, limit)] + + +def iter_obsidian_notes(vault: Path) -> list[Path]: + """Return supported Markdown notes from an Obsidian vault.""" + root = vault.expanduser().resolve() + if not root.exists() or not root.is_dir(): + raise ValueError(f"Obsidian vault not found: {root}") + notes: list[Path] = [] + for path in root.rglob("*"): + if not path.is_file(): + continue + if _is_hidden_or_skipped(path, root): + continue + if path.suffix.lower() in OBSIDIAN_SUFFIXES: + notes.append(path) + return sorted(notes) + + +def import_obsidian_vault( + target: Path, + vault: Path, + *, + overwrite: bool = False, + dry_run: bool = False, + limit: int | None = None, + max_file_bytes: int = DEFAULT_MAX_FILE_BYTES, +) -> dict[str, object]: + """Copy Obsidian Markdown notes into ``raw/obsidian//`` for ingest.""" + root = target.expanduser().resolve() + vault_root = vault.expanduser().resolve() + notes = iter_obsidian_notes(vault_root) + if limit is not None: + notes = notes[:max(0, limit)] + + vault_slug = _slugify(vault_root.name) + raw_prefix = root / "raw" / "obsidian" / vault_slug + imported: list[dict[str, object]] = [] + skipped_existing: list[dict[str, object]] = [] + skipped_large: list[dict[str, object]] = [] + blocked_secret: list[dict[str, object]] = [] + read_errors: list[dict[str, object]] = [] + + for note in notes: + rel = note.relative_to(vault_root) + rel_posix = rel.as_posix() + destination = raw_prefix / rel + raw_rel = destination.relative_to(root).as_posix() + try: + data = note.read_bytes() + except OSError as exc: + read_errors.append({"path": rel_posix, "error": str(exc)}) + continue + if len(data) > max_file_bytes: + skipped_large.append({"path": rel_posix, "size_bytes": len(data)}) + continue + labels = secret_value_warnings(data.decode("utf-8", errors="replace")) + if labels: + blocked_secret.append({"path": rel_posix, "labels": labels}) + continue + if destination.exists() and not overwrite: + skipped_existing.append({"path": rel_posix, "raw_path": raw_rel}) + continue + if not dry_run: + atomic_write_bytes(destination, data) + imported.append({"path": rel_posix, "raw_path": raw_rel, "size_bytes": len(data)}) + + status = "ok" + if blocked_secret or read_errors: + status = "needs_attention" + elif skipped_large: + status = "partial" + has_wiki_dir = (root / "wiki").is_dir() + next_commands = [f"link ingest-status {root}", f"link validate {root}", f"link health {root}"] + if not has_wiki_dir: + next_commands = [f"link init {root}", *next_commands] + return { + "status": status, + "target": str(root), + "vault": str(vault_root), + "vault_name": vault_root.name, + "raw_prefix": raw_prefix.relative_to(root).as_posix(), + "dry_run": dry_run, + "overwrite": overwrite, + "note_count": len(notes), + "imported_count": len(imported), + "skipped_existing_count": len(skipped_existing), + "skipped_large_count": len(skipped_large), + "blocked_secret_count": len(blocked_secret), + "read_error_count": len(read_errors), + "imported": _limited(imported), + "skipped_existing": _limited(skipped_existing), + "skipped_large": _limited(skipped_large), + "blocked_secret": _limited(blocked_secret), + "read_errors": _limited(read_errors), + "has_wiki_dir": has_wiki_dir, + "next_prompt": f"ingest raw/obsidian/{vault_slug} into Link", + "next_commands": next_commands, + } + + +def render_import_obsidian_text(payload: dict[str, object]) -> tuple[int, str]: + """Render a concise CLI report for an Obsidian import.""" + lines = [ + f"Obsidian import: {payload['vault']}", + f"Raw destination: {payload['raw_prefix']}", + "", + f"Notes scanned: {payload['note_count']}", + f"Imported: {payload['imported_count']}", + f"Skipped existing: {payload['skipped_existing_count']}", + f"Skipped large: {payload['skipped_large_count']}", + f"Blocked for secrets: {payload['blocked_secret_count']}", + f"Read errors: {payload['read_error_count']}", + ] + if payload.get("dry_run"): + lines.append("Dry run: no files were written.") + if not payload.get("has_wiki_dir"): + lines.append("Link wiki is not initialized yet; run the init command below before ingest.") + if payload.get("imported"): + lines.append("") + lines.append("Imported raw files:") + for item in payload["imported"]: # type: ignore[index] + lines.append(f"- {item['raw_path']}") + if payload.get("blocked_secret"): + lines.append("") + lines.append("Blocked files:") + for item in payload["blocked_secret"]: # type: ignore[index] + labels = ", ".join(str(label) for label in item.get("labels", [])) + lines.append(f"- {item['path']} ({labels})") + lines.extend([ + "", + "Next:", + f" Ask your agent: {payload['next_prompt']}", + *[f" Run: {command}" for command in payload.get("next_commands", [])], + ]) + return (1 if payload.get("blocked_secret_count") or payload.get("read_error_count") else 0), "\n".join(lines) diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 6399602..3e67076 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -24,6 +24,7 @@ "forget-memory", "graph-summary", "health", + "import-obsidian", "ingest-status", "init", "memory-audit", diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index 75a5603..262aadc 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -84,6 +84,28 @@ def test_connect_command_options(self): self.assertEqual(args.python, "/tmp/python") self.assertTrue(args.json) + def test_import_obsidian_command_options(self): + parser = build_cli_parser() + + args = parser.parse_args([ + "import-obsidian", + "/tmp/vault", + "/tmp/link", + "--overwrite", + "--dry-run", + "--limit", + "12", + "--json", + ]) + + self.assertEqual(args.command, "import-obsidian") + self.assertEqual(args.vault, "/tmp/vault") + self.assertEqual(args.target, "/tmp/link") + self.assertTrue(args.overwrite) + self.assertTrue(args.dry_run) + self.assertEqual(args.limit, 12) + self.assertTrue(args.json) + def test_version_command_routes_to_handler(self): parser = build_cli_parser() @@ -228,6 +250,34 @@ def connect_handler(target, agent, **kwargs): self.assertEqual(calls[0][2]["python_cmd"], "/tmp/python") self.assertTrue(calls[0][2]["json_output"]) + def test_dispatch_routes_import_obsidian_arguments(self): + parser = build_cli_parser() + args = parser.parse_args([ + "import-obsidian", + "/tmp/vault", + "/tmp/link", + "--overwrite", + "--dry-run", + "--limit", + "3", + "--json", + ]) + calls = [] + + def import_obsidian_handler(target, vault, **kwargs): + calls.append((target, vault, kwargs)) + return 5 + + code = dispatch_cli_command(args, {"import-obsidian": import_obsidian_handler}) + + self.assertEqual(code, 5) + self.assertEqual(calls[0][0], Path("/tmp/link")) + self.assertEqual(calls[0][1], Path("/tmp/vault")) + self.assertTrue(calls[0][2]["overwrite"]) + self.assertTrue(calls[0][2]["dry_run"]) + self.assertEqual(calls[0][2]["limit"], 3) + self.assertTrue(calls[0][2]["json_output"]) + def test_dispatch_routes_welcome_arguments(self): parser = build_cli_parser() args = parser.parse_args(["welcome", "/tmp/link", "--project", "alpha", "--json"]) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 2adc909..d03f785 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -239,6 +239,22 @@ def test_demo_output_uses_copied_runtime_path(self): self.assertIn(str(target.resolve() / "link.py"), out.getvalue()) self.assertIn(str(target.resolve()), out.getvalue()) + def test_import_obsidian_copies_notes_to_raw(self): + tmp = Path(tempfile.mkdtemp(prefix="link-obsidian-cli-")) + target = tmp / "link" + vault = tmp / "vault" + (vault / "Architecture").mkdir(parents=True) + (vault / "Architecture" / "Memory.md").write_text("# Memory\n\nUse local files.\n", encoding="utf-8") + + out = StringIO() + with redirect_stdout(out): + code = link_cli.import_obsidian(target, vault) + + self.assertEqual(code, 0) + self.assertTrue((target / "raw/obsidian/vault/Architecture/Memory.md").exists()) + self.assertIn("Obsidian import:", out.getvalue()) + self.assertIn("Ask your agent: ingest raw/obsidian/vault into Link", out.getvalue()) + def test_demo_refuses_to_overwrite_non_demo_directory(self): tmp = Path(tempfile.mkdtemp(prefix="link-demo-test-")) target = tmp / "not-demo" diff --git a/tests/test_obsidian_core.py b/tests/test_obsidian_core.py new file mode 100644 index 0000000..d6a85c2 --- /dev/null +++ b/tests/test_obsidian_core.py @@ -0,0 +1,65 @@ +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "mcp_package")) + +from link_core.obsidian import import_obsidian_vault, render_import_obsidian_text # noqa: E402 + + +class ObsidianCoreTests(unittest.TestCase): + def make_vault(self) -> Path: + vault = Path(tempfile.mkdtemp(prefix="link-obsidian-vault-")) + (vault / "Projects").mkdir() + (vault / ".obsidian").mkdir() + (vault / "Projects" / "Plan.md").write_text("# Plan\n\nShip Link.\n", encoding="utf-8") + (vault / "Daily.md").write_text("# Daily\n\nRemember the launch notes.\n", encoding="utf-8") + (vault / ".obsidian" / "workspace.json").write_text("{}", encoding="utf-8") + (vault / "image.png").write_bytes(b"not markdown") + return vault + + def test_import_obsidian_vault_copies_markdown_notes(self): + target = Path(tempfile.mkdtemp(prefix="link-obsidian-target-")) + vault = self.make_vault() + + payload = import_obsidian_vault(target, vault) + + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["note_count"], 2) + self.assertEqual(payload["imported_count"], 2) + raw_prefix = target / str(payload["raw_prefix"]) + self.assertTrue((raw_prefix / "Daily.md").exists()) + self.assertTrue((raw_prefix / "Projects/Plan.md").exists()) + self.assertFalse((raw_prefix / ".obsidian/workspace.json").exists()) + self.assertIn("ingest raw/obsidian", payload["next_prompt"]) + + def test_import_obsidian_vault_blocks_secret_notes(self): + target = Path(tempfile.mkdtemp(prefix="link-obsidian-target-")) + vault = self.make_vault() + (vault / "Secrets.md").write_text("token sk-ant-" + "a" * 30, encoding="utf-8") + + payload = import_obsidian_vault(target, vault) + code, text = render_import_obsidian_text(payload) + + self.assertEqual(payload["status"], "needs_attention") + self.assertEqual(payload["blocked_secret_count"], 1) + self.assertEqual(code, 1) + self.assertIn("Secrets.md", text) + self.assertFalse((target / str(payload["raw_prefix"]) / "Secrets.md").exists()) + + def test_import_obsidian_vault_dry_run_does_not_write(self): + target = Path(tempfile.mkdtemp(prefix="link-obsidian-target-")) + vault = self.make_vault() + + payload = import_obsidian_vault(target, vault, dry_run=True) + + self.assertEqual(payload["imported_count"], 2) + self.assertTrue(payload["dry_run"]) + self.assertFalse((target / "raw").exists()) + + +if __name__ == "__main__": + unittest.main() From 4c2a49e9fda8b490218bc7ae983b4d293cd45b9d Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 22:00:09 -0600 Subject: [PATCH 11/35] Add compliance audit export --- CHANGELOG.md | 1 + README.md | 5 ++ docs/cli.html | 2 + link.py | 35 +++++++++ mcp_package/link_core/audit_export.py | 107 ++++++++++++++++++++++++++ mcp_package/link_core/cli_parser.py | 15 ++++ scripts/check_tool_contract.py | 1 + tests/test_audit_export_core.py | 77 ++++++++++++++++++ tests/test_cli_parser_core.py | 50 ++++++++++++ tests/test_link_cli.py | 18 +++++ 10 files changed, 311 insertions(+) create mode 100644 mcp_package/link_core/audit_export.py create mode 100644 tests/test_audit_export_core.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e50520..0d67950 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 `link connect ` to preview or write MCP client config for Codex, Kiro, Claude Code, Cursor, Antigravity, VS Code, and Copilot. - Added optional `review_after` dates for durable memories so time-sensitive context can automatically return to the memory inbox for re-checking. - Added `link import-obsidian ` to copy Obsidian Markdown notes into `raw/obsidian/` with secret scanning before the normal ingest workflow. +- Added `link compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/README.md b/README.md index 5d05364..cc23126 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,11 @@ Use `review_after` for time-sensitive preferences or decisions. When that date arrives, the memory reappears in Link's review inbox so an agent can ask the user to confirm, update, archive, or forget it instead of trusting stale context. +For team handoff or security review, `link compliance-export --output audit.json` +writes a redacted JSON packet with readiness, validation, memory review status, +operation markers, and recent audit log entries. Raw source contents and memory +bodies are not included. + ## Agent Contract Agents should use Link in this order: diff --git a/docs/cli.html b/docs/cli.html index df6ad53..8f7d3bb 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -95,6 +95,7 @@

Maintenance

link health link status --validate link memory-audit +link compliance-export --output link-audit.json link operations link benchmark "agent memory" link rebuild-index @@ -123,6 +124,7 @@

All Commands

link status [--validate] link operations [--limit 20] link backup [--label name] [--include-raw] +link compliance-export [dir] [--output audit.json] [--project slug] link ingest-status link import-obsidian <vault> [dir] [--dry-run] [--overwrite] link remember "text" [--project slug] [--review-after YYYY-MM-DD] diff --git a/link.py b/link.py index 87bb0e7..8d68a25 100644 --- a/link.py +++ b/link.py @@ -12,6 +12,7 @@ python link.py health [target] python link.py operations [target] python link.py backup [target] + python link.py compliance-export [target] python link.py doctor [target] python link.py migrate [target] python link.py validate [target] @@ -128,6 +129,11 @@ create_backup as _core_create_backup, list_backups as _core_list_backups, ) +from link_core.audit_export import ( + build_compliance_export as _core_build_compliance_export, + render_compliance_export_text as _core_render_compliance_export_text, + write_compliance_export as _core_write_compliance_export, +) from link_core.benchmark import ( build_benchmark_payload as _core_build_benchmark_payload, render_benchmark_text as _core_render_benchmark_text, @@ -748,6 +754,34 @@ def backup( return code +def compliance_export( + target: Path, + output: str | None = None, + project: str | None = None, + limit: int = 100, + json_output: bool = False, +) -> int: + target = target.expanduser().resolve() + wiki_dir = _resolve_wiki_dir(target) + payload = _core_build_compliance_export( + wiki_dir, + version=LINK_VERSION, + project=project or _default_project(target), + limit=limit, + ) + if output: + output_path = Path(output).expanduser() + _core_write_compliance_export(output_path, payload) + if json_output: + print(json.dumps({"wrote": str(output_path), "export": payload}, indent=2)) + return 0 + code, text = _core_render_compliance_export_text(payload, output=str(output_path)) + print(text) + return code + print(json.dumps(payload, indent=2)) + return 0 + + def ingest_status(target: Path, json_output: bool = False) -> int: target = target.expanduser().resolve() status = _collect_ingest_status(target) @@ -1833,6 +1867,7 @@ def main(argv: list[str] | None = None) -> int: "health": health, "operations": operations, "backup": backup, + "compliance-export": compliance_export, "doctor": doctor, "migrate": migrate, "validate": validate, diff --git a/mcp_package/link_core/audit_export.py b/mcp_package/link_core/audit_export.py new file mode 100644 index 0000000..59956de --- /dev/null +++ b/mcp_package/link_core/audit_export.py @@ -0,0 +1,107 @@ +"""Compliance-style audit exports for Link.""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Mapping + +from .files import atomic_write_json +from .log import utc_timestamp +from .memory import memory_inbox, memory_profile, memory_records, slim_memory +from .operations import pending_operations +from .status import link_status + + +LOG_HEADING_RE = re.compile(r"^## \[(?P[^\]]+)\] (?P[^|]+)\| (?P.*)$") + + +def log_entries(wiki_dir: Path, *, limit: int = 100) -> list[dict[str, object]]: + """Return recent structured entries from ``wiki/log.md`` without raw content.""" + log_path = wiki_dir / "log.md" + if not log_path.exists(): + return [] + try: + lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return [] + entries: list[dict[str, object]] = [] + current: dict[str, object] | None = None + for line in lines: + match = LOG_HEADING_RE.match(line.strip()) + if match: + if current: + entries.append(current) + current = { + "timestamp": match.group("timestamp").strip(), + "operation": match.group("operation").strip(), + "description": match.group("description").strip(), + "details": [], + } + continue + if current is None: + continue + stripped = line.strip() + if stripped == "---": + entries.append(current) + current = None + elif stripped.startswith("- "): + details = current.setdefault("details", []) + if isinstance(details, list): + details.append(stripped[2:]) + if current: + entries.append(current) + return entries[-max(1, min(limit, 500)):] + + +def build_compliance_export( + wiki_dir: Path, + *, + version: str = "", + project: str | None = None, + limit: int = 100, +) -> dict[str, object]: + """Build a read-only audit packet for security reviews and team handoffs.""" + wiki = wiki_dir.expanduser().resolve() + records = memory_records(wiki, include_body=False) if wiki.exists() else [] + profile = memory_profile(records, limit=min(limit, 50), project=project) + inbox = memory_inbox(records, limit=min(limit, 50), include_archived=True, project=project) + status = link_status(wiki, version=version, records=records, include_validation=True) + operations = pending_operations(wiki, limit=min(limit, 100)) if wiki.exists() else [] + return { + "schema": "link-compliance-export-v1", + "generated_at": utc_timestamp(), + "version": version, + "wiki": str(wiki), + "project": str(profile.get("project") or ""), + "status": status, + "memory_profile": profile, + "memory_inbox": inbox, + "memories": [slim_memory(record) for record in records[:max(1, min(limit, 500))]], + "operations": operations, + "log_entries": log_entries(wiki, limit=limit), + "privacy_note": "Raw source contents and memory bodies are not included in this export.", + } + + +def write_compliance_export(path: Path, payload: Mapping[str, object]) -> None: + """Write an audit export JSON file atomically.""" + atomic_write_json(path.expanduser(), dict(payload)) + + +def render_compliance_export_text(payload: Mapping[str, object], *, output: str = "") -> tuple[int, str]: + """Render a short CLI summary after writing or printing an audit export.""" + status = payload.get("status") + ready = "" + if isinstance(status, Mapping): + ready = "yes" if status.get("ready") else "no" + lines = [ + "Link compliance export", + f"Wiki: {payload.get('wiki')}", + f"Ready: {ready or 'unknown'}", + f"Memories exported: {len(payload.get('memories') or [])}", + f"Log entries exported: {len(payload.get('log_entries') or [])}", + "Raw source contents and memory bodies were not included.", + ] + if output: + lines.insert(1, f"Wrote: {output}") + return 0, "\n".join(lines) diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index cf3a593..aa5bd3a 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -71,6 +71,13 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum backup_cmd.add_argument("--list", action="store_true", dest="list_only", help="list recent backups instead of creating one") backup_cmd.add_argument("--json", action="store_true", help="print machine-readable backup status") + compliance_cmd = sub.add_parser("compliance-export", help="export a redacted audit packet for security or team review") + compliance_cmd.add_argument("target", nargs="?", default=".") + compliance_cmd.add_argument("--output", default=None, help="write JSON to this file instead of stdout") + compliance_cmd.add_argument("--project", default=None, help="filter project-scoped memory context") + compliance_cmd.add_argument("--limit", type=int, default=100, help="maximum memories/log entries to include") + compliance_cmd.add_argument("--json", action="store_true", help="print machine-readable export status after writing --output") + doctor_cmd = sub.add_parser("doctor", help="check a Link wiki for common health issues") doctor_cmd.add_argument("target", nargs="?", default=".") doctor_cmd.add_argument("--fix", action="store_true", help="repair safe structural and backlink issues") @@ -308,6 +315,14 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: list_only=args.list_only, json_output=args.json, ) + if command == "compliance-export": + return handlers["compliance-export"]( + Path(args.target), + output=args.output, + project=args.project, + limit=args.limit, + json_output=args.json, + ) if command == "doctor": return handlers["doctor"](Path(args.target), fix=args.fix) if command == "migrate": diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 3e67076..4569fcc 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -17,6 +17,7 @@ "capture-inbox", "capture-session", "connect", + "compliance-export", "delete-capture", "demo", "doctor", diff --git a/tests/test_audit_export_core.py b/tests/test_audit_export_core.py new file mode 100644 index 0000000..06cdb01 --- /dev/null +++ b/tests/test_audit_export_core.py @@ -0,0 +1,77 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "mcp_package")) + +from link_core.audit_export import build_compliance_export, log_entries, write_compliance_export # noqa: E402 +from link_core.log import append_log # noqa: E402 +from link_core.schema import migrate_wiki # noqa: E402 +from link_core.wiki import build_backlinks # noqa: E402 + + +class AuditExportCoreTests(unittest.TestCase): + def make_wiki(self) -> Path: + root = Path(tempfile.mkdtemp(prefix="link-audit-export-")) + wiki = root / "wiki" + migrate_wiki(wiki) + (wiki / "index.md").write_text("# Index\n", encoding="utf-8") + (wiki / "log.md").write_text("# Link Wiki Log\n\n", encoding="utf-8") + memory = ( + "---\n" + "type: memory\n" + "title: Prefer Local Memory\n" + "memory_type: preference\n" + "scope: user\n" + "status: active\n" + "date_captured: \"2026-05-25T00:00:00Z\"\n" + "source: unit test\n" + "review_status: reviewed\n" + "---\n\n" + "# Prefer Local Memory\n\n" + "> **TLDR:** User prefers local memory.\n\n" + "## Memory\n\nUser prefers local memory.\n\n" + "## Source\n\nunit test\n" + ) + (wiki / "memories/prefer-local-memory.md").write_text(memory, encoding="utf-8") + (wiki / "_backlinks.json").write_text(json.dumps(build_backlinks(wiki, body_only=False)), encoding="utf-8") + append_log(wiki, "2026-05-25T00:00:00Z", "remember", "Prefer Local Memory", ["Created memory page"]) + return wiki + + def test_log_entries_parse_recent_operations(self): + wiki = self.make_wiki() + + entries = log_entries(wiki) + + self.assertEqual(entries[-1]["operation"], "remember") + self.assertEqual(entries[-1]["description"], "Prefer Local Memory") + self.assertEqual(entries[-1]["details"], ["Created memory page"]) + + def test_build_compliance_export_excludes_memory_body(self): + wiki = self.make_wiki() + + payload = build_compliance_export(wiki, version="1.3.0") + + self.assertEqual(payload["schema"], "link-compliance-export-v1") + self.assertEqual(payload["status"]["version"], "1.3.0") + self.assertEqual(payload["memory_profile"]["memory_count"], 1) + self.assertNotIn("body", payload["memories"][0]) + self.assertIn("Raw source contents", payload["privacy_note"]) + + def test_write_compliance_export_writes_json(self): + wiki = self.make_wiki() + payload = build_compliance_export(wiki, version="1.3.0") + output = wiki.parent / "audit.json" + + write_compliance_export(output, payload) + + data = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(data["schema"], "link-compliance-export-v1") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index 262aadc..0cee372 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -106,6 +106,28 @@ def test_import_obsidian_command_options(self): self.assertEqual(args.limit, 12) self.assertTrue(args.json) + def test_compliance_export_command_options(self): + parser = build_cli_parser() + + args = parser.parse_args([ + "compliance-export", + "/tmp/link", + "--output", + "/tmp/audit.json", + "--project", + "alpha", + "--limit", + "25", + "--json", + ]) + + self.assertEqual(args.command, "compliance-export") + self.assertEqual(args.target, "/tmp/link") + self.assertEqual(args.output, "/tmp/audit.json") + self.assertEqual(args.project, "alpha") + self.assertEqual(args.limit, 25) + self.assertTrue(args.json) + def test_version_command_routes_to_handler(self): parser = build_cli_parser() @@ -278,6 +300,34 @@ def import_obsidian_handler(target, vault, **kwargs): self.assertEqual(calls[0][2]["limit"], 3) self.assertTrue(calls[0][2]["json_output"]) + def test_dispatch_routes_compliance_export_arguments(self): + parser = build_cli_parser() + args = parser.parse_args([ + "compliance-export", + "/tmp/link", + "--output", + "/tmp/audit.json", + "--project", + "alpha", + "--limit", + "25", + "--json", + ]) + calls = [] + + def compliance_handler(target, **kwargs): + calls.append((target, kwargs)) + return 6 + + code = dispatch_cli_command(args, {"compliance-export": compliance_handler}) + + self.assertEqual(code, 6) + self.assertEqual(calls[0][0], Path("/tmp/link")) + self.assertEqual(calls[0][1]["output"], "/tmp/audit.json") + self.assertEqual(calls[0][1]["project"], "alpha") + self.assertEqual(calls[0][1]["limit"], 25) + self.assertTrue(calls[0][1]["json_output"]) + def test_dispatch_routes_welcome_arguments(self): parser = build_cli_parser() args = parser.parse_args(["welcome", "/tmp/link", "--project", "alpha", "--json"]) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index d03f785..2e978b7 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -255,6 +255,24 @@ def test_import_obsidian_copies_notes_to_raw(self): self.assertIn("Obsidian import:", out.getvalue()) self.assertIn("Ask your agent: ingest raw/obsidian/vault into Link", out.getvalue()) + def test_compliance_export_writes_redacted_audit_json(self): + tmp = Path(tempfile.mkdtemp(prefix="link-compliance-cli-")) + target = tmp / "demo" + output = tmp / "audit.json" + create_demo_quiet(target) + + out = StringIO() + with redirect_stdout(out): + code = link_cli.compliance_export(target, output=str(output)) + + self.assertEqual(code, 0) + self.assertTrue(output.exists()) + data = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(data["schema"], "link-compliance-export-v1") + self.assertIn("memory_profile", data) + self.assertNotIn("body", json.dumps(data["memories"])) + self.assertIn("Link compliance export", out.getvalue()) + def test_demo_refuses_to_overwrite_non_demo_directory(self): tmp = Path(tempfile.mkdtemp(prefix="link-demo-test-")) target = tmp / "not-demo" From 4b909f5b5de3d3ac78d23c8c59fb3c3e3affe3ed Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 22:08:05 -0600 Subject: [PATCH 12/35] Broaden local secret detection --- CHANGELOG.md | 1 + README.md | 5 +++-- docs/security.html | 2 +- mcp_package/link_core/security.py | 10 ++++++++- tests/test_security_core.py | 37 +++++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d67950..da01fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ### Changed +- Broadened local secret detection for common modern provider tokens and credentials before capture, ingest, Obsidian import, and doctor scans. - Broadened Windows CI from a small portability subset to most non-installer/non-server tests. - Clarified that the Homebrew formula lives in the separate `gowtham0992/homebrew-link` tap. - Tightened security reporting guidance to prefer private maintainer contact before public GitHub issues. diff --git a/README.md b/README.md index cc23126..944b2a1 100644 --- a/README.md +++ b/README.md @@ -353,8 +353,9 @@ Link itself is local-first: - No external API calls from `serve.py` or `link-mcp`. - Raw sources and generated wiki pages are ignored by git by default. - `link backup` excludes `raw/` unless you explicitly pass `--include-raw`. -- Secret-looking values are detected in raw sources, captures, and release - hygiene checks. +- Secret-looking API keys, provider tokens, JWTs, registry credentials, and + private key blocks are detected in raw sources, captures, and release hygiene + checks. - The local web server binds to `127.0.0.1` and is not meant to be exposed to the internet without additional auth. diff --git a/docs/security.html b/docs/security.html index 9d744e7..fa482f6 100644 --- a/docs/security.html +++ b/docs/security.html @@ -63,7 +63,7 @@

Privacy Model

The public GitHub Pages documentation may use lightweight analytics to understand install interest. It does not run inside Link, read local wiki data, or capture source/memory content.

Secret Handling

-

Link scans raw sources, captures, release files, and public artifacts for secret-looking values. It detects common API keys and token formats, warns without logging secret values, and refuses normal ingest guidance when raw safety cannot be established.

+

Link scans raw sources, captures, release files, and public artifacts for secret-looking values. It detects common API keys, provider tokens, JWTs, private key blocks, and registry credentials, warns without logging secret values, and refuses normal ingest guidance when raw safety cannot be established.

link ingest-status
 link capture-inbox
 link redact-capture raw/memory-captures/<capture>.md
diff --git a/mcp_package/link_core/security.py b/mcp_package/link_core/security.py
index f9cd2a1..170e4e9 100644
--- a/mcp_package/link_core/security.py
+++ b/mcp_package/link_core/security.py
@@ -8,13 +8,21 @@
 
 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")),
+    ("OpenAI API key", re.compile(r"\bsk-(?!ant-)[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")),
+    ("Hugging Face token", re.compile(r"\bhf_[A-Za-z0-9]{20,}\b")),
+    ("npm token", re.compile(r"\bnpm_[A-Za-z0-9]{20,}\b")),
+    ("Vercel token", re.compile(r"\bvercel_[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")),
+    ("Azure storage account key", re.compile(r"(?i)\b(?:AccountKey|AZURE_STORAGE_KEY)\s*[:=]\s*[A-Za-z0-9+/=]{40,}\b")),
+    ("Datadog API key", re.compile(r"(?i)\b(?:DD_API_KEY|DATADOG_API_KEY)\s*[:=]\s*[0-9a-f]{32}\b")),
+    ("Sentry DSN", re.compile(r"\bhttps://[0-9a-f]{32}@[A-Za-z0-9.-]+/[0-9]+\b")),
+    ("JWT token", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b")),
+    ("Docker registry auth", re.compile(r'(?is)"auths"\s*:\s*\{.{0,2000}?"auth"\s*:\s*"[A-Za-z0-9+/=]{20,}"')),
     ("Private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
 )
 
diff --git a/tests/test_security_core.py b/tests/test_security_core.py
index 007fc58..d9b4bc8 100644
--- a/tests/test_security_core.py
+++ b/tests/test_security_core.py
@@ -32,6 +32,43 @@ def test_secret_warnings_and_redaction(self):
         self.assertNotIn(fake_key, redacted)
         self.assertIn("[redacted-secret]", redacted)
 
+    def test_secret_warnings_cover_common_provider_tokens(self):
+        text = "\n".join(
+            [
+                "hf_" + "A" * 24,
+                "npm_" + "b" * 24,
+                "vercel_" + "C" * 24,
+                "AccountKey=" + "D" * 48,
+                "DD_API_KEY=" + "a" * 32,
+                "https://" + ("b" * 32) + "@sentry.example.com/42",
+                "eyJ" + ("a" * 16) + "." + ("b" * 16) + "." + ("c" * 16),
+                '{"auths":{"registry.example.com":{"auth":"' + ("d" * 24) + '"}}}',
+            ]
+        )
+
+        warnings = secret_value_warnings(text)
+
+        self.assertEqual(
+            warnings,
+            [
+                "Hugging Face token",
+                "npm token",
+                "Vercel token",
+                "Azure storage account key",
+                "Datadog API key",
+                "Sentry DSN",
+                "JWT token",
+                "Docker registry auth",
+            ],
+        )
+
+    def test_anthropic_key_is_not_double_reported_as_openai(self):
+        fake_key = "sk-ant-" + "a" * 48
+
+        warnings = secret_value_warnings(f"token {fake_key}")
+
+        self.assertEqual(warnings, ["Anthropic API key"])
+
     def test_secret_file_warnings_streams_across_chunks(self):
         tmp = Path(tempfile.mkdtemp(prefix="link-security-core-"))
         fake_key = "sk-" + "a" * 48

From c11e8df4c62066e9d0334de5820ffd9b0d32c979 Mon Sep 17 00:00:00 2001
From: Gowtham 
Date: Mon, 25 May 2026 22:27:24 -0600
Subject: [PATCH 13/35] Add Git team sync guidance

---
 CHANGELOG.md                        |   1 +
 README.md                           |   8 ++
 docs/cli.html                       |   3 +
 link.py                             |  17 +++
 mcp_package/link_core/cli_parser.py |   7 ++
 mcp_package/link_core/demo.py       |  19 ++++
 mcp_package/link_core/team_sync.py  | 165 ++++++++++++++++++++++++++++
 scripts/check_tool_contract.py      |   1 +
 tests/test_cli_parser_core.py       |  31 ++++++
 tests/test_link_cli.py              |   1 +
 tests/test_team_sync_core.py        |  63 +++++++++++
 11 files changed, 316 insertions(+)
 create mode 100644 mcp_package/link_core/team_sync.py
 create mode 100644 tests/test_team_sync_core.py

diff --git a/CHANGELOG.md b/CHANGELOG.md
index da01fd1..8577c0e 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 optional `review_after` dates for durable memories so time-sensitive context can automatically return to the memory inbox for re-checking.
 - Added `link import-obsidian ` to copy Obsidian Markdown notes into `raw/obsidian/` with secret scanning before the normal ingest workflow.
 - Added `link compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review.
+- Added `link team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically.
 - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists.
 - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits.
 
diff --git a/README.md b/README.md
index 944b2a1..c6fc39f 100644
--- a/README.md
+++ b/README.md
@@ -329,6 +329,14 @@ writes a redacted JSON packet with readiness, validation, memory review status,
 operation markers, and recent audit log entries. Raw source contents and memory
 bodies are not included.
 
+For Git-backed team memory, `link team-sync ~/link` checks whether the workspace
+is ready to share reviewed `wiki/` pages while keeping `raw/`, caches, backups,
+and local MCP Python markers private by default.
+
+```bash
+link team-sync ~/link --remote git@example.com:team/link-memory.git
+```
+
 ## Agent Contract
 
 Agents should use Link in this order:
diff --git a/docs/cli.html b/docs/cli.html
index 8f7d3bb..d200976 100644
--- a/docs/cli.html
+++ b/docs/cli.html
@@ -96,6 +96,7 @@ 

Maintenance

link status --validate link memory-audit link compliance-export --output link-audit.json +link team-sync ~/link link operations link benchmark "agent memory" link rebuild-index @@ -103,6 +104,7 @@

Maintenance

link validate link verify-mcp

Use link backup before broad repair work. Use link benchmark when a wiki starts to feel slow. link status --validate and link benchmark both show persistent-cache reuse so you can tell whether Link is rereading every page or reusing unchanged records.

+

Use link team-sync before sharing Link through Git. It is read-only: it checks Git state, verifies that raw/ is protected, and prints paste-safe setup/sync commands without pushing private source material for you.

Use link connect <agent> when an agent already has Link instructions but still needs MCP wiring. It previews the config before writing.

link connect codex ~/link
 link connect codex ~/link --write
@@ -125,6 +127,7 @@ 

All Commands

link operations [--limit 20] link backup [--label name] [--include-raw] link compliance-export [dir] [--output audit.json] [--project slug] +link team-sync [dir] [--remote git-url] link ingest-status link import-obsidian <vault> [dir] [--dry-run] [--overwrite] link remember "text" [--project slug] [--review-after YYYY-MM-DD] diff --git a/link.py b/link.py index 8d68a25..c4db019 100644 --- a/link.py +++ b/link.py @@ -13,6 +13,7 @@ python link.py operations [target] python link.py backup [target] python link.py compliance-export [target] + python link.py team-sync [target] python link.py doctor [target] python link.py migrate [target] python link.py validate [target] @@ -134,6 +135,10 @@ render_compliance_export_text as _core_render_compliance_export_text, write_compliance_export as _core_write_compliance_export, ) +from link_core.team_sync import ( + build_team_sync_payload as _core_build_team_sync_payload, + render_team_sync_text as _core_render_team_sync_text, +) from link_core.benchmark import ( build_benchmark_payload as _core_build_benchmark_payload, render_benchmark_text as _core_render_benchmark_text, @@ -782,6 +787,17 @@ def compliance_export( return 0 +def team_sync(target: Path, remote: str | None = None, json_output: bool = False) -> int: + target = target.expanduser().resolve() + payload = _core_build_team_sync_payload(target, remote=remote) + if json_output: + print(json.dumps(payload, indent=2)) + return 0 + code, text = _core_render_team_sync_text(payload) + print(text) + return code + + def ingest_status(target: Path, json_output: bool = False) -> int: target = target.expanduser().resolve() status = _collect_ingest_status(target) @@ -1868,6 +1884,7 @@ def main(argv: list[str] | None = None) -> int: "operations": operations, "backup": backup, "compliance-export": compliance_export, + "team-sync": team_sync, "doctor": doctor, "migrate": migrate, "validate": validate, diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index aa5bd3a..96cadc0 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -78,6 +78,11 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum compliance_cmd.add_argument("--limit", type=int, default=100, help="maximum memories/log entries to include") compliance_cmd.add_argument("--json", action="store_true", help="print machine-readable export status after writing --output") + team_sync_cmd = sub.add_parser("team-sync", help="print a safe Git plan for sharing reviewed Link memory") + team_sync_cmd.add_argument("target", nargs="?", default=".") + team_sync_cmd.add_argument("--remote", default=None, help="optional Git remote URL to include in setup commands") + team_sync_cmd.add_argument("--json", action="store_true", help="print machine-readable team sync guidance") + doctor_cmd = sub.add_parser("doctor", help="check a Link wiki for common health issues") doctor_cmd.add_argument("target", nargs="?", default=".") doctor_cmd.add_argument("--fix", action="store_true", help="repair safe structural and backlink issues") @@ -323,6 +328,8 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: limit=args.limit, json_output=args.json, ) + if command == "team-sync": + return handlers["team-sync"](Path(args.target), remote=args.remote, json_output=args.json) if command == "doctor": return handlers["doctor"](Path(args.target), fix=args.fix) if command == "migrate": diff --git a/mcp_package/link_core/demo.py b/mcp_package/link_core/demo.py index f28b60a..9bf6d74 100644 --- a/mcp_package/link_core/demo.py +++ b/mcp_package/link_core/demo.py @@ -21,6 +21,22 @@ ) RUNTIME_FILES = ("serve.py", "link.py", "LINK.md", ".linkignore") BRAND_FILES = ("logo.png", "logo.svg") +WORKSPACE_GITIGNORE = """# Link private local source material +raw/* +!raw/.gitkeep + +# Link local runtime state +.link-backups/ +.link-cache/ +.link-mcp-python + +# Local/editor noise +.DS_Store +*.swp +*~ +__pycache__/ +*.pyc +""" class DemoError(RuntimeError): @@ -50,6 +66,9 @@ def copy_runtime_files(source_root: Path, target: Path) -> None: dst = target / name if src.exists() and src.resolve() != dst.resolve(): shutil.copy2(src, dst) + gitignore = target / ".gitignore" + if not gitignore.exists(): + atomic_write_text(gitignore, WORKSPACE_GITIGNORE) def create_demo_workspace(target: Path, *, source_root: Path, force: bool = False) -> dict[str, object]: diff --git a/mcp_package/link_core/team_sync.py b/mcp_package/link_core/team_sync.py new file mode 100644 index 0000000..aea6c76 --- /dev/null +++ b/mcp_package/link_core/team_sync.py @@ -0,0 +1,165 @@ +"""Read-only Git team-sync guidance for Link workspaces.""" +from __future__ import annotations + +import configparser +from pathlib import Path +from typing import Mapping + +from .mcp_verify import display_command + + +def _link_root(target: Path) -> Path: + root = target.expanduser().resolve() + if root.name == "wiki" and (root / "_link_schema.json").exists(): + return root.parent + return root + + +def _find_git_root(start: Path) -> Path | None: + current = start + for candidate in (current, *current.parents): + if (candidate / ".git").exists(): + return candidate + return None + + +def _git_remote_names(git_root: Path | None) -> list[str]: + if git_root is None: + return [] + config_path = git_root / ".git" / "config" + if not config_path.exists() or not config_path.is_file(): + return [] + parser = configparser.ConfigParser() + try: + parser.read(config_path, encoding="utf-8") + except configparser.Error: + return [] + names: list[str] = [] + for section in parser.sections(): + if section.startswith('remote "') and section.endswith('"'): + names.append(section.removeprefix('remote "').removesuffix('"')) + return sorted(names) + + +def _gitignore_raw_status(root: Path) -> dict[str, object]: + path = root / ".gitignore" + if not path.exists(): + return {"path": str(path), "exists": False, "protects_raw": False} + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError as exc: + return {"path": str(path), "exists": True, "protects_raw": False, "error": str(exc)} + normalized = {line.strip().replace("\\", "/") for line in lines if line.strip() and not line.lstrip().startswith("#")} + protects_raw = any(line in {"raw/", "raw/*", "/raw/", "/raw/*"} for line in normalized) + return {"path": str(path), "exists": True, "protects_raw": protects_raw} + + +def _action(label: str, command: list[str]) -> dict[str, str]: + return { + "label": label, + "command_text": display_command(command), + } + + +def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[str, object]: + """Return a read-only plan for sharing a Link workspace through Git.""" + root = _link_root(target) + wiki_dir = root / "wiki" + git_root = _find_git_root(root) + remotes = _git_remote_names(git_root) + gitignore = _gitignore_raw_status(root) + remote_clean = str(remote or "").strip() + + warnings: list[str] = [] + if not wiki_dir.exists(): + warnings.append("Link wiki is missing. Run link init before preparing team sync.") + if git_root and not bool(gitignore.get("protects_raw")): + warnings.append("raw/ is not protected by the workspace .gitignore; do not push until raw sources are intentionally handled.") + if git_root and not remotes and not remote_clean: + warnings.append("Git repository has no remote configured.") + + setup_actions: list[dict[str, str]] = [] + sync_actions: list[dict[str, str]] = [ + _action("check Link health", ["link", "health", str(root)]), + _action("review pending memories", ["link", "memory-inbox", str(root)]), + _action("validate before sharing", ["link", "validate", str(root)]), + _action("backup before sharing", ["link", "backup", str(root)]), + ] + if git_root is None: + setup_actions.extend([ + _action("initialize Git", ["git", "-C", str(root), "init"]), + _action("stage shared memory files", ["git", "-C", str(root), "add", "wiki", "LINK.md", ".gitignore"]), + _action("commit shared memory baseline", ["git", "-C", str(root), "commit", "-m", "Initialize Link shared memory"]), + ]) + if remote_clean: + setup_actions.append(_action("add remote", ["git", "-C", str(root), "remote", "add", "origin", remote_clean])) + setup_actions.append(_action("push first branch", ["git", "-C", str(root), "push", "-u", "origin", "main"])) + else: + sync_actions.extend([ + _action("inspect changes", ["git", "-C", str(git_root), "status", "--short"]), + _action("pull first", ["git", "-C", str(git_root), "pull", "--ff-only"]), + _action("stage shared memory files", ["git", "-C", str(git_root), "add", str(root / "wiki"), str(root / "LINK.md"), str(root / ".gitignore")]), + _action("commit reviewed memory updates", ["git", "-C", str(git_root), "commit", "-m", "Update Link shared memory"]), + ]) + if remotes or remote_clean: + if remote_clean and not remotes: + sync_actions.append(_action("add remote", ["git", "-C", str(git_root), "remote", "add", "origin", remote_clean])) + sync_actions.append(_action("push reviewed updates", ["git", "-C", str(git_root), "push"])) + + return { + "target": str(root), + "wiki": str(wiki_dir), + "git_root": str(git_root) if git_root else "", + "in_git": git_root is not None, + "remote": remote_clean, + "remotes": remotes, + "gitignore": gitignore, + "ready": bool(wiki_dir.exists() and git_root and gitignore.get("protects_raw")), + "warnings": warnings, + "setup_actions": setup_actions, + "sync_actions": sync_actions, + "notes": [ + "Share wiki/ and LINK.md for team agent memory.", + "Keep raw/ private unless every source is approved for the team.", + "Review memory inbox and validation before pushing shared memory updates.", + ], + } + + +def render_team_sync_text(payload: Mapping[str, object]) -> tuple[int, str]: + """Render Git team-sync guidance without running Git commands.""" + ready = bool(payload.get("ready")) + lines = [ + f"Link team sync: {payload.get('target')}", + "", + f"Status: {'ready for reviewed Git sharing' if ready else 'needs setup or review'}", + f"Git: {payload.get('git_root') or 'not initialized'}", + f"raw/ protection: {'ok' if (payload.get('gitignore') or {}).get('protects_raw') else 'needs review'}", + ] + remotes = payload.get("remotes") + if isinstance(remotes, list) and remotes: + lines.append("Remotes: " + ", ".join(str(item) for item in remotes)) + warnings = payload.get("warnings") + if isinstance(warnings, list) and warnings: + lines.extend(["", "Warnings:"]) + lines.extend(f"- {warning}" for warning in warnings) + + setup_actions = payload.get("setup_actions") + if isinstance(setup_actions, list) and setup_actions: + lines.extend(["", "One-time setup:"]) + for action in setup_actions: + if isinstance(action, Mapping): + lines.append(f"- {action.get('label')}: {action.get('command_text')}") + + sync_actions = payload.get("sync_actions") + if isinstance(sync_actions, list) and sync_actions: + lines.extend(["", "Safe sync loop:"]) + for action in sync_actions: + if isinstance(action, Mapping): + lines.append(f"- {action.get('label')}: {action.get('command_text')}") + + notes = payload.get("notes") + if isinstance(notes, list) and notes: + lines.extend(["", "Notes:"]) + lines.extend(f"- {note}" for note in notes) + return 0, "\n".join(lines) diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 4569fcc..4ae29d0 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -47,6 +47,7 @@ "review-memory", "serve", "status", + "team-sync", "try", "update-memory", "validate", diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index 0cee372..279524c 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -128,6 +128,22 @@ def test_compliance_export_command_options(self): self.assertEqual(args.limit, 25) self.assertTrue(args.json) + def test_team_sync_command_options(self): + parser = build_cli_parser() + + args = parser.parse_args([ + "team-sync", + "/tmp/link", + "--remote", + "git@example.com:team/link-memory.git", + "--json", + ]) + + self.assertEqual(args.command, "team-sync") + self.assertEqual(args.target, "/tmp/link") + self.assertEqual(args.remote, "git@example.com:team/link-memory.git") + self.assertTrue(args.json) + def test_version_command_routes_to_handler(self): parser = build_cli_parser() @@ -137,6 +153,21 @@ def test_version_command_routes_to_handler(self): self.assertEqual(args.command, "version") self.assertEqual(code, 42) + def test_dispatch_routes_team_sync_arguments(self): + parser = build_cli_parser() + calls = [] + + args = parser.parse_args(["team-sync", "/tmp/link", "--remote", "git@example.com:team/link.git", "--json"]) + code = dispatch_cli_command( + args, + {"team-sync": lambda *args, **kwargs: calls.append((args, kwargs)) or 0}, + ) + + self.assertEqual(code, 0) + self.assertEqual(calls[0][0][0], Path("/tmp/link")) + self.assertEqual(calls[0][1]["remote"], "git@example.com:team/link.git") + self.assertTrue(calls[0][1]["json_output"]) + def test_welcome_project_and_json_options(self): parser = build_cli_parser() diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 2e978b7..2929573 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -41,6 +41,7 @@ def test_init_creates_empty_wiki(self): self.assertTrue((target / "serve.py").exists()) self.assertTrue((target / "link.py").exists()) self.assertTrue((target / "LINK.md").exists()) + self.assertIn("raw/*", (target / ".gitignore").read_text(encoding="utf-8")) self.assertTrue((target / "link_core/frontmatter.py").exists()) self.assertTrue((target / "raw").is_dir()) self.assertTrue((target / "wiki/index.md").exists()) diff --git a/tests/test_team_sync_core.py b/tests/test_team_sync_core.py new file mode 100644 index 0000000..db7546e --- /dev/null +++ b/tests/test_team_sync_core.py @@ -0,0 +1,63 @@ +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "mcp_package")) + +from link_core.team_sync import build_team_sync_payload, render_team_sync_text # noqa: E402 + + +class TeamSyncCoreTests(unittest.TestCase): + def test_plan_for_workspace_without_git_includes_safe_setup(self): + root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) + (root / "wiki").mkdir() + (root / "wiki" / "_link_schema.json").write_text("{}", encoding="utf-8") + (root / ".gitignore").write_text("raw/*\n.link-backups/\n", encoding="utf-8") + + payload = build_team_sync_payload(root, remote="git@example.com:team/link-memory.git") + + self.assertFalse(payload["in_git"]) + self.assertFalse(payload["ready"]) + self.assertTrue(payload["gitignore"]["protects_raw"]) + commands = [action["command_text"] for action in payload["setup_actions"]] + self.assertTrue(any("git" in command and "init" in command for command in commands)) + self.assertTrue(any("remote" in command and "add" in command for command in commands)) + + def test_git_workspace_with_raw_protection_is_ready(self): + root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) + (root / "wiki").mkdir() + (root / "wiki" / "_link_schema.json").write_text("{}", encoding="utf-8") + (root / "LINK.md").write_text("# Link\n", encoding="utf-8") + (root / ".gitignore").write_text("raw/*\n.link-backups/\n", encoding="utf-8") + (root / ".git").mkdir() + (root / ".git" / "config").write_text( + '[remote "origin"]\n\turl = git@example.com:team/link-memory.git\n', + encoding="utf-8", + ) + + payload = build_team_sync_payload(root) + code, text = render_team_sync_text(payload) + + self.assertEqual(code, 0) + self.assertTrue(payload["ready"]) + self.assertEqual(payload["remotes"], ["origin"]) + self.assertIn("ready for reviewed Git sharing", text) + self.assertIn("Safe sync loop", text) + + def test_git_workspace_without_raw_protection_warns(self): + root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) + (root / "wiki").mkdir() + (root / "wiki" / "_link_schema.json").write_text("{}", encoding="utf-8") + (root / ".git").mkdir() + + payload = build_team_sync_payload(root) + + self.assertFalse(payload["ready"]) + self.assertIn("raw/ is not protected", payload["warnings"][0]) + + +if __name__ == "__main__": + unittest.main() From 6b5f73d381cd69539957883845fd7c1af2a7e8d4 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 22:49:56 -0600 Subject: [PATCH 14/35] Add expiring memories --- CHANGELOG.md | 1 + README.md | 6 ++- docs/api.html | 1 + docs/cli.html | 3 +- docs/concepts.html | 3 +- docs/mcp.html | 2 +- link.py | 4 ++ mcp_package/README.md | 5 +- mcp_package/link_core/cli_memory.py | 2 + mcp_package/link_core/cli_parser.py | 2 + mcp_package/link_core/memory.py | 77 +++++++++++++++++++++++++---- mcp_package/link_core/validation.py | 10 ++++ mcp_package/link_core/web_memory.py | 2 + mcp_package/link_mcp/server.py | 6 ++- serve.py | 1 + tests/test_cli_memory_core.py | 2 + tests/test_cli_parser_core.py | 3 ++ tests/test_mcp_contract.py | 6 +++ tests/test_memory_core.py | 46 +++++++++++++++++ tests/test_serve.py | 5 ++ tests/test_validation_core.py | 27 ++++++++++ 21 files changed, 197 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8577c0e..f9ba154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link try` as a one-command demo proof loop that creates the demo, checks readiness, runs query/brief examples, and prints first agent prompts. - Added `link connect ` to preview or write MCP client config for Codex, Kiro, Claude Code, Cursor, Antigravity, VS Code, and Copilot. - Added optional `review_after` dates for durable memories so time-sensitive context can automatically return to the memory inbox for re-checking. +- Added optional `expires_at` dates for durable memories so temporary context automatically leaves default recall after expiry. - Added `link import-obsidian ` to copy Obsidian Markdown notes into `raw/obsidian/` with secret scanning before the normal ingest workflow. - Added `link compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review. - Added `link team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically. diff --git a/README.md b/README.md index c6fc39f..af87cdd 100644 --- a/README.md +++ b/README.md @@ -316,13 +316,17 @@ creating a duplicate. - `ingest_status`: exact next steps for raw files, including source safety, stale ingest detection, validation, and memory proposal guidance. - `remember_memory`: durable local memory with duplicate/conflict checks, - review state, optional `review_after` re-check dates, provenance, and audit logging. + review state, optional `review_after` re-check dates, optional `expires_at` + expiry dates, provenance, and audit logging. - `explain_memory`: why a memory exists, what it links to, whether it is ready for recall, and what needs review. Use `review_after` for time-sensitive preferences or decisions. When that date arrives, the memory reappears in Link's review inbox so an agent can ask the user to confirm, update, archive, or forget it instead of trusting stale context. +Use `expires_at` for temporary context that should automatically leave default +recall after a date; Link keeps the Markdown page inspectable and asks the user +to update, archive, or delete it. For team handoff or security review, `link compliance-export --output audit.json` writes a redacted JSON packet with readiness, validation, memory review status, diff --git a/docs/api.html b/docs/api.html index 9eb06ba..ef1c3cf 100644 --- a/docs/api.html +++ b/docs/api.html @@ -90,6 +90,7 @@

Write Endpoints

POST /api/rebuild-backlinks POST /api/rebuild-index

Web memory approval APIs intentionally do not honor duplicate/conflict override flags. If Link reports a duplicate or conflict, review the existing memory and use the CLI or MCP tool explicitly after deciding what should coexist.

+

POST /api/remember-memory accepts optional review_after and expires_at dates in YYYY-MM-DD form for scheduled review and temporary-memory expiry.

Large Wiki Endpoints

Agents and integrations should prefer bounded endpoints over full dumps:

diff --git a/docs/cli.html b/docs/cli.html index d200976..26ad49a 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -67,6 +67,7 @@

Daily Loop

link health link ingest-status link remember "User prefers feature branches for Link work." --type preference --scope project --project link --review-after 2026-08-01 +link remember "Temporary launch branch is release/one-off." --type project --project link --expires-at 2026-09-01 link brief "working on Link release" --project link link query "what should I know before changing the MCP tools?" --budget small --project link link validate @@ -130,7 +131,7 @@

All Commands

link team-sync [dir] [--remote git-url] link ingest-status link import-obsidian <vault> [dir] [--dry-run] [--overwrite] -link remember "text" [--project slug] [--review-after YYYY-MM-DD] +link remember "text" [--project slug] [--review-after YYYY-MM-DD] [--expires-at YYYY-MM-DD] link propose-memories <file-or-text> [--project slug] link capture-session <file-or-text> [--project slug] link capture-inbox [--project slug] diff --git a/docs/concepts.html b/docs/concepts.html index d880b83..37be744 100644 --- a/docs/concepts.html +++ b/docs/concepts.html @@ -84,12 +84,13 @@

Three User Moves

Raw files do not silently personalize future agents. Ingest creates source-backed wiki knowledge. Explicit remember creates durable user or project memory.

Memory Lifecycle

-

A memory is a Markdown page with status, scope, source, review state, optional review_after date, graph links, and local log entries. It can be proposed, remembered, reviewed, updated, archived, restored, explained, or forgotten.

+

A memory is a Markdown page with status, scope, source, review state, optional review_after and expires_at dates, graph links, and local log entries. It can be proposed, remembered, reviewed, updated, archived, restored, explained, or forgotten.

Propose

Generate candidate memories from chat notes or raw captures without writing durable memory.

Approve

Save only the memories the user explicitly wants agents to carry forward.

Explain

Show why a memory exists, whether it is recall-ready, and what graph links support it.

Re-check

Use review_after when a memory should come back to the inbox after a date instead of staying trusted forever.

+

Expire

Use expires_at for temporary context that should leave default recall after a date while staying inspectable.

Smart Query Packets

diff --git a/docs/mcp.html b/docs/mcp.html index 8408cc9..87e0e5b 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -164,7 +164,7 @@

MCP Tools

get_graph rebuild_index rebuild_backlinks -

Memory write tools return duplicate_candidates or conflict_candidates when review, update, or archive is safer than creating another memory page.

+

Memory write tools return duplicate_candidates or conflict_candidates when review, update, or archive is safer than creating another memory page. remember_memory also accepts optional review_after and expires_at dates for scheduled re-checks and temporary memories.

Project-aware tools accept an optional project argument. When set, Link returns broad user/global memory plus memories for that project, while excluding memories from other explicit projects.

Verify Setup

diff --git a/link.py b/link.py index c4db019..9d55273 100644 --- a/link.py +++ b/link.py @@ -560,6 +560,7 @@ def _write_memory_page( timestamp: str | None = None, allow_duplicate: bool = False, allow_conflict: bool = False, project: str | None = None, review_after: str | None = None, + expires_at: str | None = None, ) -> dict[str, object]: wiki_dir, records = _memory_runtime(target) clean_text = _required_memory_text(text, "memory text required") @@ -568,6 +569,7 @@ def _write_memory_page( wiki_dir, clean_text, title=title, memory_type=memory_type, scope=scope, tags=tags, source=source, review_after=review_after, + expires_at=expires_at, allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, **options, ) @@ -885,6 +887,7 @@ def remember( allow_conflict: bool = False, project: str | None = None, review_after: str | None = None, + expires_at: str | None = None, json_output: bool = False, ) -> int: if not text or not text.strip(): @@ -903,6 +906,7 @@ def remember( allow_conflict=allow_conflict, project=project or _default_project(target), review_after=review_after, + expires_at=expires_at, ) except (FileNotFoundError, ValueError) as exc: print(f"Could not remember: {exc}", file=sys.stderr) diff --git a/mcp_package/README.md b/mcp_package/README.md index eb2e27a..55723c3 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -106,7 +106,8 @@ Most agents should call: Use `remember_memory` only when the user explicitly approves saving durable memory. Add `review_after` for memories that should return to the review inbox -after a date. Use `propose_memories` or `capture_session` for proposal-only review. +after a date, or `expires_at` for temporary context that should leave default +recall after a date. Use `propose_memories` or `capture_session` for proposal-only review. For local CLI setup checks, `link verify-mcp --json` returns structured `issues` and `next_actions` that agents and scripts can consume without parsing terminal text. @@ -144,7 +145,7 @@ In the local web proposal picker, unreadable raw files are surfaced as | `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?, 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?, review_after?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. `review_after` accepts `YYYY-MM-DD` for scheduled re-checks. | +| `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?, project?, review_after?, expires_at?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. `review_after` accepts `YYYY-MM-DD` for scheduled re-checks; `expires_at` accepts `YYYY-MM-DD` for temporary memories that should leave default recall. | | `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. | | `capture_inbox(limit?, project?)` | Review saved raw captures with redacted snippets, secret-warning labels, and accept/redact/delete commands. | diff --git a/mcp_package/link_core/cli_memory.py b/mcp_package/link_core/cli_memory.py index 2f0b2d8..76bd831 100644 --- a/mcp_package/link_core/cli_memory.py +++ b/mcp_package/link_core/cli_memory.py @@ -98,6 +98,8 @@ def render_remember_text(result: Mapping[str, object], *, target: object = ".") lines.append(f"Project: {result['project']}") if result.get("review_after"): lines.append(f"Review after: {result['review_after']}") + if result.get("expires_at"): + lines.append(f"Expires at: {result['expires_at']}") lines.extend([ "", "Next:", diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index 96cadc0..c564251 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -118,6 +118,7 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum 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("--review-after", default=None, help="YYYY-MM-DD date when this memory should be checked again") + remember_cmd.add_argument("--expires-at", default=None, help="YYYY-MM-DD date when this memory should leave default recall") 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") @@ -358,6 +359,7 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: source=args.source, project=args.project, review_after=args.review_after, + expires_at=args.expires_at, allow_duplicate=args.allow_duplicate, allow_conflict=args.allow_conflict, json_output=args.json, diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index cba9994..eea4dab 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -195,22 +195,39 @@ def slim_memory(record: Mapping[str, object]) -> dict[str, object]: def is_active_memory(record: Mapping[str, object]) -> bool: - return str(record.get("status") or "active").lower() not in {"archived", "stale"} + return str(record.get("status") or "active").lower() not in {"archived", "stale"} and not memory_expired(record) -def _parse_review_date(value: object) -> date | None: +def _parse_date_field(value: object, field: str) -> date | None: text = str(value or "").strip().strip('"') if not text: return None if not DATE_RE.match(text): - raise ValueError("review_after must use YYYY-MM-DD") + raise ValueError(f"{field} must use YYYY-MM-DD") return date.fromisoformat(text) +def _parse_review_date(value: object) -> date | None: + return _parse_date_field(value, "review_after") + + +def _parse_expires_date(value: object) -> date | None: + return _parse_date_field(value, "expires_at") + + def _today(today: str | None = None) -> date: return _parse_review_date(today) if today else date.today() +def memory_expired(record: Mapping[str, object], today: str | None = None) -> bool: + """Return true when a memory has passed its optional expiry date.""" + try: + expires = _parse_expires_date(record.get("expires_at")) + except ValueError: + return False + return expires is not None and expires <= _today(today) + + def memory_visible_for_project(record: Mapping[str, object], project: str | None = None) -> bool: project_name = normalize_project(project) if not project_name: @@ -264,6 +281,7 @@ def memory_record_from_page(wiki_dir: Path, path: Path, include_body: bool = Tru "review_status": meta.get("review_status") or "pending", "reviewed_at": meta.get("reviewed_at", ""), "review_after": meta.get("review_after", ""), + "expires_at": meta.get("expires_at", ""), "review_note": meta.get("review_note", ""), "tags": meta_tags(meta.get("tags", "")), "tldr": extract_tldr(body), @@ -338,6 +356,26 @@ def memory_review_issues( "suggested_action": f"Confirm it is still accurate, then run {review_command}.", }) + expires_at = str(record.get("expires_at") or "").strip() + if expires_at: + try: + expires = _parse_expires_date(expires_at) + except ValueError as exc: + issues.append({ + "code": "invalid_expires_at", + "severity": "high", + "message": str(exc), + "suggested_action": "Use a YYYY-MM-DD date or remove expires_at.", + }) + else: + if status == "active" and expires is not None and expires <= _today(today): + issues.append({ + "code": "expired", + "severity": "high", + "message": f"Memory expired at {expires_at} and is excluded from default recall.", + "suggested_action": "Update it with a new expiry date, archive it, or delete it after confirmation.", + }) + if status == "stale": issues.append({ "code": "stale_status", @@ -466,7 +504,15 @@ def add(action: dict[str, object]) -> None: )) return actions - if issue_codes & {"invalid_review_status", "invalid_review_after", "invalid_memory_type", "invalid_scope", "missing_source", "missing_date_captured"}: + if issue_codes & { + "invalid_review_status", + "invalid_review_after", + "invalid_expires_at", + "invalid_memory_type", + "invalid_scope", + "missing_source", + "missing_date_captured", + }: add(_memory_action( kind="edit_metadata", label="Edit metadata", @@ -486,14 +532,15 @@ def add(action: dict[str, object]) -> None: arguments={"identifier": name, "memory": "new detail"}, priority="high", )) - if "stale_status" in issue_codes: + if issue_codes & {"stale_status", "expired"}: + reason = "expired" if "expired" in issue_codes else "stale" add(_memory_action( kind="archive", label="Archive", - description="Archive this stale memory so default recall ignores it.", - command=_shell_words("python3", "link.py", "archive-memory", name, command_target, "--reason", "stale"), + description="Archive this memory so default recall ignores it.", + command=_shell_words("python3", "link.py", "archive-memory", name, command_target, "--reason", reason), tool="archive_memory", - arguments={"identifier": name, "reason": "stale"}, + arguments={"identifier": name, "reason": reason}, priority="high", )) if issue_codes & {"pending_review", "review_due"} and not any( @@ -604,7 +651,10 @@ def recall_state( high_issues = [issue for issue in issues if str(issue.get("severity") or "") == "high"] if not default_enabled: state = "disabled" - reason = f"Memory status is {record.get('status')}; default recall excludes archived and stale memories." + if memory_expired(record): + reason = f"Memory expired at {record.get('expires_at')}; default recall excludes expired memories." + else: + reason = f"Memory status is {record.get('status')}; default recall excludes archived and stale memories." elif high_issues: state = "unsafe" reason = "Memory is active but has high-severity quality issues." @@ -683,6 +733,7 @@ def memory_explanation( "archived_at": record.get("archived_at", ""), "archive_reason": record.get("archive_reason", ""), "restored_at": record.get("restored_at", ""), + "expires_at": record.get("expires_at", ""), }, "graph": graph, "log_entries": memory_log_entries(wiki_dir, record), @@ -1121,6 +1172,7 @@ def write_memory_page( timestamp: str, project: str | None = None, review_after: str | None = None, + expires_at: str | None = None, records: Iterable[Mapping[str, object]] | None = None, allow_duplicate: bool = False, allow_conflict: bool = False, @@ -1139,6 +1191,9 @@ def write_memory_page( clean_review_after = str(review_after or "").strip() if clean_review_after: _parse_review_date(clean_review_after) + clean_expires_at = str(expires_at or "").strip() + if clean_expires_at: + _parse_expires_date(clean_expires_at) clean_project = normalize_project(project) if scope == "project" else "" memory_title_value = memory_title(clean_text, title) summary = clean_text.splitlines()[0].strip() @@ -1195,6 +1250,7 @@ def write_memory_page( tag_values.append(slug_tag) project_line = f'project: "{frontmatter_string(clean_project)}"\n' if clean_project else "" review_after_line = f'review_after: "{frontmatter_string(clean_review_after)}"\n' if clean_review_after else "" + expires_at_line = f'expires_at: "{frontmatter_string(clean_expires_at)}"\n' if clean_expires_at else "" page = f"""--- type: memory @@ -1205,7 +1261,7 @@ def write_memory_page( date_captured: "{timestamp}" source: "{frontmatter_string(clean_source)}" review_status: pending -{review_after_line}reviewed_at: "" +{review_after_line}{expires_at_line}reviewed_at: "" tags: {yaml_list(tag_values)} --- @@ -1256,6 +1312,7 @@ def write_memory_page( "scope": scope, "project": clean_project, "review_after": clean_review_after, + "expires_at": clean_expires_at, "backlinks_rebuilt": bool(backlinks_rebuilt), "duplicate_override": bool(duplicate_candidates and allow_duplicate), "duplicate_candidates": duplicate_candidates, diff --git a/mcp_package/link_core/validation.py b/mcp_package/link_core/validation.py index ffed088..a36b817 100644 --- a/mcp_package/link_core/validation.py +++ b/mcp_package/link_core/validation.py @@ -175,6 +175,16 @@ def validate_wiki(wiki_dir: Path, *, strict: bool = False) -> dict[str, Any]: except ValueError: findings.append(_finding("error", "invalid_review_after", rel, "review_after must be a valid calendar date.")) + expires_at = str(meta.get("expires_at") or "").strip().strip('"') + if expected_type == "memory" and expires_at: + if not DATE_RE.match(expires_at): + findings.append(_finding("error", "invalid_expires_at", rel, "expires_at must use YYYY-MM-DD.")) + else: + try: + date.fromisoformat(expires_at) + except ValueError: + findings.append(_finding("error", "invalid_expires_at", rel, "expires_at must be a valid calendar date.")) + if not SUMMARY_RE.search(body): findings.append(_finding("warning", "missing_summary", rel, "Page should include a TLDR or Query summary.")) diff --git a/mcp_package/link_core/web_memory.py b/mcp_package/link_core/web_memory.py index 3ea629c..fc82aab 100644 --- a/mcp_package/link_core/web_memory.py +++ b/mcp_package/link_core/web_memory.py @@ -148,6 +148,8 @@ def render_memory_card( meta_parts.append(f'captured {record["date_captured"]}') if record.get("review_after"): meta_parts.append(f'review after {record["review_after"]}') + if record.get("expires_at"): + meta_parts.append(f'expires {record["expires_at"]}') meta = " · ".join(part for part in meta_parts if part) issues_html = "" if include_issues and record.get("issues"): diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 54cc479..36a8496 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -743,7 +743,7 @@ def _write_mcp_memory_page( text: str, title: str = "", memory_type: str = "note", scope: str = "user", tags: str = "", source: str = "mcp", allow_duplicate: bool = False, allow_conflict: bool = False, project: str = "", - review_after: str = "", + review_after: str = "", expires_at: str = "", ) -> dict[str, object]: clean_text = _required_text_input(text, "memory text required", max_len=4000) memory_type, scope = _memory_type_scope(memory_type, scope) @@ -754,6 +754,7 @@ def _write_mcp_memory_page( memory_type=memory_type, scope=scope, tags=_clean_text_input(tags, max_len=500), source=_clean_text_input(source, max_len=500), review_after=_clean_text_input(review_after, max_len=40) or None, + expires_at=_clean_text_input(expires_at, max_len=40) or None, allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, **options, ) @@ -1173,6 +1174,7 @@ def remember_memory( allow_conflict: bool = False, project: str = "", review_after: str = "", + expires_at: str = "", ) -> str: """Save a local agent memory as a Markdown page. @@ -1185,6 +1187,7 @@ def remember_memory( project: optional project key for project-scoped memories. tags: optional comma-separated tags. review_after: optional YYYY-MM-DD date when this memory should be checked again. + expires_at: optional YYYY-MM-DD date when this memory should leave default recall. """ try: result = _write_mcp_memory_page( @@ -1198,6 +1201,7 @@ def remember_memory( allow_conflict=allow_conflict, project=project, review_after=review_after, + expires_at=expires_at, ) except ValueError as exc: return json.dumps({"created": False, "error": str(exc)}) diff --git a/serve.py b/serve.py index 67def36..9ccd530 100644 --- a/serve.py +++ b/serve.py @@ -564,6 +564,7 @@ def _remember_memory_from_web(payload: dict[str, object]) -> dict[str, object]: _utc_timestamp(), project=_clean_text_input(payload.get("project"), max_len=80) or None, review_after=_clean_text_input(payload.get("review_after"), max_len=40) or None, + expires_at=_clean_text_input(payload.get("expires_at"), max_len=40) or None, records=_memory_records(), allow_duplicate=False, allow_conflict=False, diff --git a/tests/test_cli_memory_core.py b/tests/test_cli_memory_core.py index b333525..c6f81cf 100644 --- a/tests/test_cli_memory_core.py +++ b/tests/test_cli_memory_core.py @@ -26,12 +26,14 @@ def test_render_remember_created(self): "scope": "project", "project": "link", "review_after": "2026-08-01", + "expires_at": "2026-12-01", }) self.assertEqual(code, 0) self.assertIn("Memory saved", text) self.assertIn("Project: link", text) self.assertIn("Review after: 2026-08-01", text) + self.assertIn("Expires at: 2026-12-01", text) self.assertIn("python3 link.py recall", text) self.assertIn("Prefer release branches", text) diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index 279524c..c9f98ef 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -200,11 +200,14 @@ def test_memory_choices_are_enforced(self): "user", "--review-after", "2026-06-01", + "--expires-at", + "2026-07-01", ]) self.assertEqual(args.memory_type, "preference") self.assertEqual(args.scope, "user") self.assertEqual(args.review_after, "2026-06-01") + self.assertEqual(args.expires_at, "2026-07-01") with self.assertRaises(SystemExit): parser.parse_args(["remember", "bad", "--type", "unsupported"]) diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 06fd99a..0c59e0c 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -792,12 +792,18 @@ def test_remember_memory_contract(self): scope="project", tags="git, release", source="unit test", + review_after="2026-08-01", + expires_at="2026-12-01", )) recall = json.loads(self.server.recall_memory("release branches")) + memory_text = (self.target / "wiki/memories/prefer-release-branches.md").read_text(encoding="utf-8") self.assertTrue(payload["created"]) self.assertEqual(payload["name"], "prefer-release-branches") + self.assertEqual(payload["review_after"], "2026-08-01") + self.assertEqual(payload["expires_at"], "2026-12-01") self.assertTrue((self.target / "wiki/memories/prefer-release-branches.md").exists()) + self.assertIn('expires_at: "2026-12-01"', memory_text) self.assertEqual(recall["memories"][0]["name"], "prefer-release-branches") def test_remember_memory_blocks_strong_duplicate(self): diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 5283d29..7a8056a 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -132,6 +132,32 @@ def test_review_after_marks_memory_due(self): self.assertEqual(inbox["review_count"], 1) self.assertEqual(inbox["items"][0]["primary_action"]["kind"], "review") + def test_expires_at_disables_default_recall_and_marks_inbox(self): + record = { + "name": "expired-context", + "memory_type": "project", + "scope": "user", + "status": "active", + "date_captured": "2026-05-01T00:00:00Z", + "source": "unit test", + "review_status": "reviewed", + "expires_at": "2000-01-01", + "tldr": "Temporary launch context.", + "snippet": "Temporary launch context.", + } + + issues = memory_review_issues(record, today="2026-05-25") + inbox = memory_inbox([record]) + recall = recall_memories([record], "temporary launch") + state = recall_state(record, issues) + + self.assertIn("expired", [issue["code"] for issue in issues]) + self.assertEqual(inbox["review_count"], 1) + self.assertEqual(inbox["items"][0]["primary_action"]["kind"], "archive") + self.assertEqual(recall, []) + self.assertEqual(state["state"], "disabled") + self.assertIn("expired", state["reason"]) + def test_review_after_rejects_invalid_dates(self): record = { "name": "bad-review-date", @@ -149,6 +175,23 @@ def test_review_after_rejects_invalid_dates(self): self.assertIn("invalid_review_after", [issue["code"] for issue in issues]) + def test_expires_at_rejects_invalid_dates(self): + record = { + "name": "bad-expires-date", + "memory_type": "preference", + "scope": "user", + "status": "active", + "date_captured": "2026-05-01T00:00:00Z", + "source": "unit test", + "review_status": "reviewed", + "expires_at": "later", + "tldr": "Invalid expiry date.", + } + + issues = memory_review_issues(record) + + self.assertIn("invalid_expires_at", [issue["code"] for issue in issues]) + def test_memory_inbox_returns_action_plan(self): records = [ { @@ -897,6 +940,7 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str source="unit test", timestamp="2026-05-05T06:00:00Z", review_after="2026-08-01", + expires_at="2026-12-01", records=[], log_writer=log_writer, rebuild_backlinks=lambda: rebuilds.append(True) or True, @@ -912,8 +956,10 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str self.assertIn('title: "Prefer release branches"', memory_text) self.assertIn("memory_type: preference", memory_text) self.assertIn('review_after: "2026-08-01"', memory_text) + self.assertIn('expires_at: "2026-12-01"', memory_text) self.assertIn("tags: [memory, preference, git, release]", memory_text) self.assertEqual(created["review_after"], "2026-08-01") + self.assertEqual(created["expires_at"], "2026-12-01") self.assertIn("## Source\n\nunit test", memory_text) self.assertIn("[[prefer-release-branches]]", index_text) self.assertEqual(logged[-1][1], "remember") diff --git a/tests/test_serve.py b/tests/test_serve.py index 5ae4675..3cc2522 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -1580,6 +1580,8 @@ def test_memory_approval_api_requires_header_and_writes_memory(self): "memory_type": "preference", "scope": "user", "source": "web proposal", + "review_after": "2026-08-01", + "expires_at": "2026-12-01", } denied_status, denied_payload = post_json("/api/remember-memory", payload, local_action=False) @@ -1601,6 +1603,9 @@ def test_memory_approval_api_requires_header_and_writes_memory(self): self.assertTrue(created["saved"]) self.assertTrue(created["created"]) self.assertEqual(created["path"], f"wiki/memories/{created['name']}.md") + self.assertEqual(created["review_after"], "2026-08-01") + self.assertEqual(created["expires_at"], "2026-12-01") + self.assertIn('expires_at: "2026-12-01"', page_text) self.assertEqual(duplicate_status, 409) self.assertFalse(duplicate["saved"]) self.assertTrue(duplicate["duplicate"]) diff --git a/tests/test_validation_core.py b/tests/test_validation_core.py index 9ce79a0..657c6e9 100644 --- a/tests/test_validation_core.py +++ b/tests/test_validation_core.py @@ -124,6 +124,33 @@ def test_validate_wiki_rejects_invalid_memory_review_after(self): self.assertFalse(payload["passed"]) self.assertIn("invalid_review_after", {finding["code"] for finding in payload["findings"]}) + def test_validate_wiki_rejects_invalid_memory_expires_at(self): + wiki = self.make_wiki() + write_page( + wiki, + "memories/bad-expiry-date.md", + "---\n" + "type: memory\n" + "title: Bad Expiry Date\n" + "memory_type: preference\n" + "scope: user\n" + "status: active\n" + "source: unit test\n" + "review_status: reviewed\n" + "expires_at: someday\n" + "---\n\n" + "# Bad Expiry Date\n\n" + "> **TLDR:** A memory with a bad expiry date.\n\n" + "## Memory\n\nExpire later.\n\n" + "## Source\n\nunit test\n", + ) + (wiki / "_backlinks.json").write_text(json.dumps(build_backlinks(wiki, body_only=False)), encoding="utf-8") + + payload = validate_wiki(wiki) + + self.assertFalse(payload["passed"]) + self.assertIn("invalid_expires_at", {finding["code"] for finding in payload["findings"]}) + def test_validate_wiki_reports_unreadable_pages(self): wiki = self.make_wiki() write_page( From aff60cbc719e9cba7a87e1dd5a1aca83b788281e Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 23:08:38 -0600 Subject: [PATCH 15/35] Add local page sharing --- CHANGELOG.md | 1 + README.md | 7 ++ docs/cli.html | 2 + link.py | 15 +++ mcp_package/link_core/cli_parser.py | 15 +++ mcp_package/link_core/share.py | 151 ++++++++++++++++++++++++++++ scripts/check_tool_contract.py | 1 + tests/test_cli_parser_core.py | 39 +++++++ tests/test_share_core.py | 88 ++++++++++++++++ 9 files changed, 319 insertions(+) create mode 100644 mcp_package/link_core/share.py create mode 100644 tests/test_share_core.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ba154..4a0fedc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link import-obsidian ` to copy Obsidian Markdown notes into `raw/obsidian/` with secret scanning before the normal ingest workflow. - Added `link compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review. - Added `link team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically. +- Added `link share ` to print a local viewer permalink and agent prompt for a specific Link page. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/README.md b/README.md index af87cdd..fe26a2a 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,13 @@ and local MCP Python markers private by default. link team-sync ~/link --remote git@example.com:team/link-memory.git ``` +For a teammate, reviewer, or another agent, `link share` resolves a page, +memory, title, alias, or search phrase into a local viewer URL: + +```bash +link share "Prefer local memory" ~/link +``` + ## Agent Contract Agents should use Link in this order: diff --git a/docs/cli.html b/docs/cli.html index 26ad49a..0fec91b 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -68,6 +68,7 @@

Daily Loop

link ingest-status link remember "User prefers feature branches for Link work." --type preference --scope project --project link --review-after 2026-08-01 link remember "Temporary launch branch is release/one-off." --type project --project link --expires-at 2026-09-01 +link share "Prefer local memory" link brief "working on Link release" --project link link query "what should I know before changing the MCP tools?" --budget small --project link link validate @@ -129,6 +130,7 @@

All Commands

link backup [--label name] [--include-raw] link compliance-export [dir] [--output audit.json] [--project slug] link team-sync [dir] [--remote git-url] +link share <page-or-memory> [dir] [--port 3000] link ingest-status link import-obsidian <vault> [dir] [--dry-run] [--overwrite] link remember "text" [--project slug] [--review-after YYYY-MM-DD] [--expires-at YYYY-MM-DD] diff --git a/link.py b/link.py index 9d55273..06983c4 100644 --- a/link.py +++ b/link.py @@ -14,6 +14,7 @@ python link.py backup [target] python link.py compliance-export [target] python link.py team-sync [target] + python link.py share [target] python link.py doctor [target] python link.py migrate [target] python link.py validate [target] @@ -139,6 +140,10 @@ build_team_sync_payload as _core_build_team_sync_payload, render_team_sync_text as _core_render_team_sync_text, ) +from link_core.share import ( + render_share_text as _core_render_share_text, + share_page_payload as _core_share_page_payload, +) from link_core.benchmark import ( build_benchmark_payload as _core_build_benchmark_payload, render_benchmark_text as _core_render_benchmark_text, @@ -800,6 +805,15 @@ def team_sync(target: Path, remote: str | None = None, json_output: bool = False return code +def share(target: Path, identifier: str, port: int = 3000, host: str = "127.0.0.1", json_output: bool = False) -> int: + wiki_dir = _resolve_wiki_dir(target) + if not wiki_dir.exists(): + print(f"Missing wiki directory: {wiki_dir}", file=sys.stderr) + return 1 + payload = _core_share_page_payload(wiki_dir, identifier, host=host, port=port) + return _emit_json_or_text(payload, json_output, _core_render_share_text, json_code=0 if payload.get("found") else 1) + + def ingest_status(target: Path, json_output: bool = False) -> int: target = target.expanduser().resolve() status = _collect_ingest_status(target) @@ -1889,6 +1903,7 @@ def main(argv: list[str] | None = None) -> int: "backup": backup, "compliance-export": compliance_export, "team-sync": team_sync, + "share": share, "doctor": doctor, "migrate": migrate, "validate": validate, diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index c564251..4cf94ad 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -83,6 +83,13 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum team_sync_cmd.add_argument("--remote", default=None, help="optional Git remote URL to include in setup commands") team_sync_cmd.add_argument("--json", action="store_true", help="print machine-readable team sync guidance") + share_cmd = sub.add_parser("share", help="print a local viewer permalink for a page or memory") + share_cmd.add_argument("identifier", help="page name, title, path, alias, or search query") + share_cmd.add_argument("target", nargs="?", default=".") + share_cmd.add_argument("--port", type=int, default=3000, help="local viewer port to include in the URL") + share_cmd.add_argument("--host", default="127.0.0.1", help="local viewer host to include in the URL") + share_cmd.add_argument("--json", action="store_true", help="print machine-readable share details") + doctor_cmd = sub.add_parser("doctor", help="check a Link wiki for common health issues") doctor_cmd.add_argument("target", nargs="?", default=".") doctor_cmd.add_argument("--fix", action="store_true", help="repair safe structural and backlink issues") @@ -331,6 +338,14 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: ) if command == "team-sync": return handlers["team-sync"](Path(args.target), remote=args.remote, json_output=args.json) + if command == "share": + return handlers["share"]( + Path(args.target), + args.identifier, + port=args.port, + host=args.host, + json_output=args.json, + ) if command == "doctor": return handlers["doctor"](Path(args.target), fix=args.fix) if command == "migrate": diff --git a/mcp_package/link_core/share.py b/mcp_package/link_core/share.py new file mode 100644 index 0000000..ce6c783 --- /dev/null +++ b/mcp_package/link_core/share.py @@ -0,0 +1,151 @@ +"""Local permalink helpers for Link wiki pages.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping +from urllib.parse import quote + +from .mcp_verify import display_command +from .search import close_wiki_cache, search_pages +from .wiki import build_wiki_cache + + +def _page_summary(page: Mapping[str, Any]) -> dict[str, object]: + return { + "name": page.get("name", ""), + "title": page.get("title", ""), + "path": page.get("path", ""), + "category": page.get("category", ""), + "type": page.get("type", ""), + "tldr": page.get("tldr", ""), + } + + +def _identifier_keys(identifier: str) -> list[str]: + text = identifier.strip().strip('"').strip("'") + if not text: + return [] + keys = {text.lower()} + if text.startswith("/page/"): + keys.add(text.removeprefix("/page/").lower()) + cleaned = text.removeprefix("wiki/").replace("\\", "/") + keys.add(cleaned.lower()) + if cleaned.endswith(".md"): + keys.add(Path(cleaned).stem.lower()) + keys.add(Path(cleaned).stem.lower()) + return [key for key in keys if key] + + +def _exact_page(pages: list[Mapping[str, Any]], identifier: str) -> Mapping[str, Any] | None: + keys = set(_identifier_keys(identifier)) + if not keys: + return None + for page in pages: + name = str(page.get("name") or "").lower() + path = str(page.get("path") or "").lower() + title = str(page.get("title") or "").lower() + aliases = {str(alias).lower() for alias in page.get("aliases", []) if str(alias).strip()} + if name in keys or path in keys or title in keys or keys & aliases: + return page + return None + + +def _page_url(page_name: str, *, host: str, port: int) -> str: + return f"http://{host}:{port}/page/{quote(page_name)}" + + +def share_page_payload( + wiki_dir: Path, + identifier: str, + *, + host: str = "127.0.0.1", + port: int = 3000, + cache: dict[str, Any] | None = None, +) -> dict[str, object]: + """Resolve a page or memory identifier into a local viewer permalink.""" + query = identifier.strip() + if not query: + return { + "found": False, + "error": "page or memory identifier required", + "query": query, + "candidates": [], + } + + owns_cache = cache is None + resolved_cache = cache or build_wiki_cache(wiki_dir) + try: + pages = list(resolved_cache.get("pages") or []) + page = _exact_page(pages, query) + resolution = "exact" + candidates: list[dict[str, object]] = [] + if page is None: + results = search_pages(query, resolved_cache, limit=5) + candidates = [_page_summary(result) | {"score": result.get("score", 0)} for result in results] + page = results[0] if results else None + resolution = "search" + if page is None: + return { + "found": False, + "error": "no matching Link page found", + "query": query, + "candidates": candidates, + } + + summary = _page_summary(page) + page_name = str(summary["name"]) + root = wiki_dir.parent if wiki_dir.name == "wiki" else wiki_dir + serve_command = ["link", "serve", str(root), "--port", str(port)] + return { + "found": True, + "query": query, + "resolution": resolution, + "page": summary, + "url": _page_url(page_name, host=host, port=port), + "serve_command": serve_command, + "serve_command_text": display_command(serve_command), + "agent_prompt": f"open Link page {page_name} and summarize why it matters", + "candidates": candidates, + } + finally: + if owns_cache: + close_wiki_cache(resolved_cache) + + +def render_share_text(payload: Mapping[str, object]) -> tuple[int, str]: + """Render a local permalink payload for CLI users.""" + if not payload.get("found"): + lines = [ + "Link share: no matching page", + "", + f"Query: {payload.get('query') or ''}", + f"Error: {payload.get('error') or 'not found'}", + ] + candidates = payload.get("candidates") + if isinstance(candidates, list) and candidates: + lines.extend(["", "Closest matches:"]) + for candidate in candidates[:5]: + if isinstance(candidate, Mapping): + title = candidate.get("title") or candidate.get("name") + path = candidate.get("path") or "" + lines.append(f"- {title} ({path})") + return 1, "\n".join(lines) + + page = payload.get("page") if isinstance(payload.get("page"), Mapping) else {} + lines = [ + "Link share", + "", + f"Page: {page.get('title') or page.get('name')}", + f"Path: {page.get('path')}", + f"URL: {payload.get('url')}", + "", + "If the viewer is not running:", + f" {payload.get('serve_command_text')}", + "", + "Agent prompt:", + f" {payload.get('agent_prompt')}", + ] + if payload.get("resolution") == "search": + lines.append("") + lines.append("Resolved by search. Use the exact page name for a stable direct match.") + return 0, "\n".join(lines) diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 4ae29d0..2298a70 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -46,6 +46,7 @@ "restore-memory", "review-memory", "serve", + "share", "status", "team-sync", "try", diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index c9f98ef..f7fd1fe 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -84,6 +84,27 @@ def test_connect_command_options(self): self.assertEqual(args.python, "/tmp/python") self.assertTrue(args.json) + def test_share_command_options(self): + parser = build_cli_parser() + + args = parser.parse_args([ + "share", + "Prefer local memory", + "/tmp/link", + "--port", + "3456", + "--host", + "localhost", + "--json", + ]) + + self.assertEqual(args.command, "share") + self.assertEqual(args.identifier, "Prefer local memory") + self.assertEqual(args.target, "/tmp/link") + self.assertEqual(args.port, 3456) + self.assertEqual(args.host, "localhost") + self.assertTrue(args.json) + def test_import_obsidian_command_options(self): parser = build_cli_parser() @@ -306,6 +327,24 @@ def connect_handler(target, agent, **kwargs): self.assertEqual(calls[0][2]["python_cmd"], "/tmp/python") self.assertTrue(calls[0][2]["json_output"]) + def test_dispatch_routes_share_arguments(self): + parser = build_cli_parser() + args = parser.parse_args(["share", "Prefer local memory", "/tmp/link", "--port", "3456", "--host", "localhost", "--json"]) + calls = [] + + def share_handler(target, identifier, **kwargs): + calls.append((target, identifier, kwargs)) + return 9 + + code = dispatch_cli_command(args, {"share": share_handler}) + + self.assertEqual(code, 9) + self.assertEqual(calls[0][0], Path("/tmp/link")) + self.assertEqual(calls[0][1], "Prefer local memory") + self.assertEqual(calls[0][2]["port"], 3456) + self.assertEqual(calls[0][2]["host"], "localhost") + self.assertTrue(calls[0][2]["json_output"]) + def test_dispatch_routes_import_obsidian_arguments(self): parser = build_cli_parser() args = parser.parse_args([ diff --git a/tests/test_share_core.py b/tests/test_share_core.py new file mode 100644 index 0000000..b56c2dc --- /dev/null +++ b/tests/test_share_core.py @@ -0,0 +1,88 @@ +import tempfile +import unittest +from pathlib import Path + +from mcp_package.link_core.share import render_share_text, share_page_payload + + +class ShareCoreTests(unittest.TestCase): + def make_wiki(self) -> Path: + root = Path(tempfile.mkdtemp(prefix="link-share-core-")) + wiki = root / "wiki" + (wiki / "memories").mkdir(parents=True) + (wiki / "concepts").mkdir(parents=True) + (wiki / "memories" / "prefer-local-memory.md").write_text( + "---\n" + "type: memory\n" + "title: Prefer local memory\n" + "aliases: [local preference]\n" + "memory_type: preference\n" + "scope: user\n" + "status: active\n" + "date_captured: \"2026-05-25T00:00:00Z\"\n" + "source: unit test\n" + "review_status: reviewed\n" + "---\n\n" + "# Prefer local memory\n\n" + "> **TLDR:** User prefers local agent memory.\n\n" + "## Memory\n\nUser prefers local agent memory.\n\n" + "## Source\n\nunit test\n", + encoding="utf-8", + ) + (wiki / "concepts" / "agent-memory.md").write_text( + "---\n" + "type: concept\n" + "title: Agent memory\n" + "aliases: [AI memory]\n" + "---\n\n" + "# Agent memory\n\n" + "> **TLDR:** Durable context for AI agents.\n\n" + "## Overview\n\nAgent memory helps agents recall project context.\n", + encoding="utf-8", + ) + return wiki + + def test_share_resolves_exact_memory_title(self): + wiki = self.make_wiki() + + payload = share_page_payload(wiki, "Prefer local memory", port=3456) + + self.assertTrue(payload["found"]) + self.assertEqual(payload["resolution"], "exact") + self.assertEqual(payload["page"]["name"], "prefer-local-memory") + self.assertEqual(payload["url"], "http://127.0.0.1:3456/page/prefer-local-memory") + self.assertIn("link serve", payload["serve_command_text"]) + + def test_share_resolves_path_alias_and_search(self): + wiki = self.make_wiki() + + path_payload = share_page_payload(wiki, "wiki/memories/prefer-local-memory.md") + alias_payload = share_page_payload(wiki, "AI memory") + search_payload = share_page_payload(wiki, "durable context") + + self.assertEqual(path_payload["page"]["name"], "prefer-local-memory") + self.assertEqual(alias_payload["page"]["name"], "agent-memory") + self.assertEqual(search_payload["resolution"], "search") + self.assertEqual(search_payload["page"]["name"], "agent-memory") + + def test_share_missing_page_returns_candidates(self): + wiki = self.make_wiki() + + payload = share_page_payload(wiki, "local") + code, text = render_share_text(payload) + + self.assertTrue(payload["found"]) + self.assertEqual(code, 0) + self.assertIn("Link share", text) + + def test_render_share_not_found(self): + code, text = render_share_text({ + "found": False, + "query": "missing", + "error": "no matching Link page found", + "candidates": [{"title": "Agent memory", "path": "wiki/concepts/agent-memory.md"}], + }) + + self.assertEqual(code, 1) + self.assertIn("no matching page", text) + self.assertIn("Closest matches", text) From 0c388a7b63b9cc8275fc0b209b704bceb3248c4b Mon Sep 17 00:00:00 2001 From: Gowtham Date: Mon, 25 May 2026 23:51:13 -0600 Subject: [PATCH 16/35] Add memory changelog --- CHANGELOG.md | 1 + README.md | 6 + docs/api.html | 1 + docs/cli.html | 1 + docs/mcp.html | 1 + link.py | 20 +++ mcp_package/README.md | 3 +- mcp_package/link_core/audit_export.py | 41 +----- mcp_package/link_core/cli_memory.py | 41 ++++++ mcp_package/link_core/cli_parser.py | 13 ++ mcp_package/link_core/log.py | 45 +++++++ mcp_package/link_core/memory_log.py | 156 ++++++++++++++++++++++ mcp_package/link_core/web_layout.py | 1 + mcp_package/link_core/web_memory_pages.py | 47 +++++++ mcp_package/link_mcp/server.py | 23 ++++ scripts/check_tool_contract.py | 2 + serve.py | 26 ++++ tests/test_cli_parser_core.py | 11 ++ tests/test_log_core.py | 21 ++- tests/test_mcp_contract.py | 14 ++ tests/test_memory_log_core.py | 67 ++++++++++ tests/test_serve.py | 25 ++++ tests/test_web_memory_pages_core.py | 26 ++++ 23 files changed, 551 insertions(+), 41 deletions(-) create mode 100644 mcp_package/link_core/memory_log.py create mode 100644 tests/test_memory_log_core.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a0fedc..2b11b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review. - Added `link team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically. - Added `link share ` to print a local viewer permalink and agent prompt for a specific Link page. +- Added `link memory-log`, MCP `memory_log`, `/memory-log`, and `/api/memory-log` for recent memory lifecycle changes without exposing raw source or memory bodies. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/README.md b/README.md index fe26a2a..5122e55 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,8 @@ creating a duplicate. expiry dates, provenance, and audit logging. - `explain_memory`: why a memory exists, what it links to, whether it is ready for recall, and what needs review. +- `memory_log`: recent memory lifecycle changes from `wiki/log.md`, without + raw source or memory bodies. Use `review_after` for time-sensitive preferences or decisions. When that date arrives, the memory reappears in Link's review inbox so an agent can ask the @@ -333,6 +335,10 @@ writes a redacted JSON packet with readiness, validation, memory review status, operation markers, and recent audit log entries. Raw source contents and memory bodies are not included. +For day-to-day auditability, `link memory-log ~/link` shows what Link recently +remembered, updated, reviewed, archived, restored, forgot, or accepted from raw +captures. + For Git-backed team memory, `link team-sync ~/link` checks whether the workspace is ready to share reviewed `wiki/` pages while keeping `raw/`, caches, backups, and local MCP Python markers private by default. diff --git a/docs/api.html b/docs/api.html index ef1c3cf..8483fad 100644 --- a/docs/api.html +++ b/docs/api.html @@ -67,6 +67,7 @@

Read Endpoints

GET /api/memory-audit?project=slug GET /api/memory-profile?project=slug GET /api/memory-inbox?project=slug +GET /api/memory-log?limit=50 GET /api/capture-inbox?project=slug GET /api/explain-memory?memory=name GET /api/query-link?q=query&budget=small|medium|large diff --git a/docs/cli.html b/docs/cli.html index 0fec91b..5584204 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -148,6 +148,7 @@

All Commands

link recall "query" [--project slug] link profile [--project slug] link memory-inbox [--project slug] +link memory-log [--limit N] link review-memory <name> link explain-memory <name> link update-memory <name> "text" [--project slug] diff --git a/docs/mcp.html b/docs/mcp.html index 87e0e5b..8deedd3 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -142,6 +142,7 @@

MCP Tools

memory_audit memory_profile memory_inbox +memory_log review_memory explain_memory search_wiki diff --git a/link.py b/link.py index 06983c4..58ab62f 100644 --- a/link.py +++ b/link.py @@ -35,6 +35,7 @@ python link.py restore-memory [target] python link.py forget-memory [target] --confirm python link.py memory-inbox [target] + python link.py memory-log [target] python link.py review-memory [target] python link.py explain-memory [target] python link.py rebuild-index [target] @@ -136,6 +137,9 @@ render_compliance_export_text as _core_render_compliance_export_text, write_compliance_export as _core_write_compliance_export, ) +from link_core.memory_log import ( + memory_log_payload as _core_memory_log_payload, +) from link_core.team_sync import ( build_team_sync_payload as _core_build_team_sync_payload, render_team_sync_text as _core_render_team_sync_text, @@ -178,6 +182,7 @@ render_forget_memory_text as _core_render_forget_memory_text, render_memory_audit_text as _core_render_memory_audit_text, render_memory_inbox_text as _core_render_memory_inbox_text, + render_memory_log_text as _core_render_memory_log_text, render_memory_status_text as _core_render_memory_status_text, render_profile_text as _core_render_profile_text, render_propose_memories_text as _core_render_propose_memories_text, @@ -1442,6 +1447,20 @@ def memory_inbox( ) +def memory_log(target: Path, limit: int = 50, include_captures: bool = True, 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 = _core_memory_log_payload(wiki_dir, limit=limit, include_captures=include_captures) + return _emit_json_or_text( + payload, + json_output, + lambda data: _core_render_memory_log_text(data, target=target), + ) + + def review_memory(target: Path, identifier: str, note: str | None = None, json_output: bool = False) -> int: try: result = _mark_memory_reviewed(target, identifier, note=note) @@ -1928,6 +1947,7 @@ def main(argv: list[str] | None = None) -> int: "restore-memory": restore_memory, "forget-memory": forget_memory, "memory-inbox": memory_inbox, + "memory-log": memory_log, "review-memory": review_memory, "explain-memory": explain_memory, "rebuild-index": rebuild_index, diff --git a/mcp_package/README.md b/mcp_package/README.md index 55723c3..d30ac05 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -142,6 +142,7 @@ In the local web proposal picker, unreadable raw files are surfaced as | `memory_audit(limit?, project?)` | Read-only health report for memory review backlog, saved raw captures, risk factors, and next actions. | | `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. | +| `memory_log(limit?, include_captures?)` | List recent memory lifecycle changes from `wiki/log.md` without raw source or memory bodies. | | `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?, project?)` | Search durable local memories for preferences, decisions, and project context. | @@ -165,7 +166,7 @@ In the local web proposal picker, unreadable raw files are surfaced as | `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 status reports pending, failed, or interrupted writes, call `link_operations` before attempting repair. If the user asks what to try after install, call `starter_prompts`. If status 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; if it returns `blocked_secrets`, `blocked_raw_access`, `blocked_source_access`, scan warnings, or secret warnings, do not read or ingest flagged raw files until the user redacts them, fixes local file access, or repairs unreadable source pages. 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. Use `get_graph_summary` before `get_graph` when the wiki may be large or the agent only needs graph orientation. +Use `link_status` when connecting to Link or troubleshooting setup; if status reports pending, failed, or interrupted writes, call `link_operations` before attempting repair. If the user asks what to try after install, call `starter_prompts`. If status 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; if it returns `blocked_secrets`, `blocked_raw_access`, `blocked_source_access`, scan warnings, or secret warnings, do not read or ingest flagged raw files until the user redacts them, fixes local file access, or repairs unreadable source pages. 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, `memory_log` to see recent memory lifecycle changes without raw bodies, `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 `get_graph_summary` before `get_graph` when the wiki may be large or the agent only needs graph orientation. Web approval APIs keep the safe path only: duplicate/conflict overrides should go through CLI or MCP after explicit human review. diff --git a/mcp_package/link_core/audit_export.py b/mcp_package/link_core/audit_export.py index 59956de..d5788c1 100644 --- a/mcp_package/link_core/audit_export.py +++ b/mcp_package/link_core/audit_export.py @@ -1,56 +1,19 @@ """Compliance-style audit exports for Link.""" from __future__ import annotations -import re from pathlib import Path from typing import Mapping from .files import atomic_write_json -from .log import utc_timestamp +from .log import read_log_entries, utc_timestamp from .memory import memory_inbox, memory_profile, memory_records, slim_memory from .operations import pending_operations from .status import link_status -LOG_HEADING_RE = re.compile(r"^## \[(?P[^\]]+)\] (?P[^|]+)\| (?P.*)$") - - def log_entries(wiki_dir: Path, *, limit: int = 100) -> list[dict[str, object]]: """Return recent structured entries from ``wiki/log.md`` without raw content.""" - log_path = wiki_dir / "log.md" - if not log_path.exists(): - return [] - try: - lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() - except OSError: - return [] - entries: list[dict[str, object]] = [] - current: dict[str, object] | None = None - for line in lines: - match = LOG_HEADING_RE.match(line.strip()) - if match: - if current: - entries.append(current) - current = { - "timestamp": match.group("timestamp").strip(), - "operation": match.group("operation").strip(), - "description": match.group("description").strip(), - "details": [], - } - continue - if current is None: - continue - stripped = line.strip() - if stripped == "---": - entries.append(current) - current = None - elif stripped.startswith("- "): - details = current.setdefault("details", []) - if isinstance(details, list): - details.append(stripped[2:]) - if current: - entries.append(current) - return entries[-max(1, min(limit, 500)):] + return read_log_entries(wiki_dir, limit=max(1, min(limit, 500))) def build_compliance_export( diff --git a/mcp_package/link_core/cli_memory.py b/mcp_package/link_core/cli_memory.py index 76bd831..446877b 100644 --- a/mcp_package/link_core/cli_memory.py +++ b/mcp_package/link_core/cli_memory.py @@ -331,6 +331,47 @@ def render_memory_inbox_text( return 0, "\n".join(lines) +def render_memory_log_text(payload: Mapping[str, object], *, target: object) -> tuple[int, str]: + lines = [ + f"Link memory log: {target}", + f"{payload.get('count', 0)} recent memory event(s)", + str(payload.get("privacy_note") or ""), + "", + ] + entries = payload.get("entries", []) + if not isinstance(entries, Sequence) or isinstance(entries, (str, bytes)) or not entries: + lines.extend([ + "No memory lifecycle events yet.", + "", + "Next:", + f" {_shell_words('python3', 'link.py', 'remember', 'a useful preference or decision', target)}", + ]) + return 0, "\n".join(lines) + for entry in entries: + if not isinstance(entry, Mapping): + continue + lines.append( + f"- {entry.get('timestamp', '')} · {entry.get('operation', '')} · {entry.get('description', '')}" + ) + summary = str(entry.get("summary") or "").strip() + if summary: + lines.append(f" {summary}") + paths = entry.get("memory_paths", []) + if isinstance(paths, Sequence) and not isinstance(paths, (str, bytes)) and paths: + lines.append(" Memories: " + ", ".join(str(path) for path in paths)) + details = entry.get("details", []) + if isinstance(details, Sequence) and not isinstance(details, (str, bytes)): + for detail in list(details)[:4]: + lines.append(f" - {detail}") + actions = payload.get("next_actions", []) + if isinstance(actions, Sequence) and not isinstance(actions, (str, bytes)) and actions: + lines.extend(["", "Next actions:"]) + for action in actions: + if isinstance(action, Mapping): + lines.append(f"- {action.get('label')}: {action.get('command')}") + return 0, "\n".join(lines) + + def render_explain_memory_text(explanation: Mapping[str, object]) -> tuple[int, str]: memory = explanation["memory"] recall_info = explanation["recall"] diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index 4cf94ad..b09a6eb 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -258,6 +258,12 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum 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") + memory_log_cmd = sub.add_parser("memory-log", help="show recent memory lifecycle events from wiki/log.md") + memory_log_cmd.add_argument("target", nargs="?", default=".") + memory_log_cmd.add_argument("--limit", type=int, default=50) + memory_log_cmd.add_argument("--no-captures", action="store_true", help="hide raw capture lifecycle events") + memory_log_cmd.add_argument("--json", action="store_true", help="print machine-readable memory log") + review_cmd = sub.add_parser("review-memory", help="mark a memory as reviewed") review_cmd.add_argument("identifier", help="memory page name, title, or path") review_cmd.add_argument("target", nargs="?", default=".") @@ -495,6 +501,13 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: project=args.project, json_output=args.json, ) + if command == "memory-log": + return handlers["memory-log"]( + Path(args.target), + limit=args.limit, + include_captures=not args.no_captures, + json_output=args.json, + ) if command == "review-memory": return handlers["review-memory"](Path(args.target), args.identifier, note=args.note, json_output=args.json) if command == "explain-memory": diff --git a/mcp_package/link_core/log.py b/mcp_package/link_core/log.py index 62b6fff..aee7127 100644 --- a/mcp_package/link_core/log.py +++ b/mcp_package/link_core/log.py @@ -1,6 +1,7 @@ """Shared Link log helpers.""" from __future__ import annotations +import re from datetime import datetime, timezone from pathlib import Path @@ -9,6 +10,7 @@ DEFAULT_LOG_TEXT = "# Link Wiki Log\n\n*Append-only record of wiki operations.*\n" DEFAULT_LOG_MAX_BYTES = 2 * 1024 * 1024 DEFAULT_LOG_BACKUPS = 5 +LOG_HEADING_RE = re.compile(r"^## \[(?P[^\]]+)\] (?P[^|]+)\| (?P.*)$") def utc_timestamp() -> str: @@ -40,3 +42,46 @@ def append_log( max_bytes=max_bytes, backups=backups, ) + + +def read_log_entries(wiki_dir: Path, *, limit: int = 100) -> list[dict[str, object]]: + """Return recent structured entries from ``wiki/log.md``.""" + try: + parsed_limit = int(limit) + except (TypeError, ValueError): + parsed_limit = 100 + limit = max(1, min(parsed_limit, 1000)) + log_path = wiki_dir / "log.md" + if not log_path.exists(): + return [] + try: + lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return [] + entries: list[dict[str, object]] = [] + current: dict[str, object] | None = None + for line in lines: + match = LOG_HEADING_RE.match(line.strip()) + if match: + if current: + entries.append(current) + current = { + "timestamp": match.group("timestamp").strip(), + "operation": match.group("operation").strip(), + "description": match.group("description").strip(), + "details": [], + } + continue + if current is None: + continue + stripped = line.strip() + if stripped == "---": + entries.append(current) + current = None + elif stripped.startswith("- "): + details = current.setdefault("details", []) + if isinstance(details, list): + details.append(stripped[2:]) + if current: + entries.append(current) + return entries[-limit:] diff --git a/mcp_package/link_core/memory_log.py b/mcp_package/link_core/memory_log.py new file mode 100644 index 0000000..97b4e30 --- /dev/null +++ b/mcp_package/link_core/memory_log.py @@ -0,0 +1,156 @@ +"""Memory timeline helpers for Link.""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Mapping + +from .log import read_log_entries + + +MEMORY_OPERATIONS = { + "accept-capture", + "archive-memory", + "forget-memory", + "remember", + "restore-memory", + "review-memory", + "update-memory", +} +CAPTURE_OPERATIONS = { + "capture-session", + "delete-capture", + "redact-capture", +} +MEMORY_PATH_RE = re.compile(r"(?:wiki/)?memories/([A-Za-z0-9._-]+\.md)") + + +def memory_log_payload( + wiki_dir: Path, + *, + limit: int = 50, + include_captures: bool = True, +) -> dict[str, object]: + """Return recent memory lifecycle events from the local operation log.""" + try: + parsed_limit = int(limit) + except (TypeError, ValueError): + parsed_limit = 50 + limit = max(1, min(parsed_limit, 200)) + operations = set(MEMORY_OPERATIONS) + if include_captures: + operations.update(CAPTURE_OPERATIONS) + entries = [] + for entry in read_log_entries(wiki_dir, limit=1000): + normalized = _memory_log_entry(entry) + if normalized["operation"] in operations or normalized["memory_paths"]: + entries.append(normalized) + recent = entries[-limit:] + return { + "schema": "link-memory-log-v1", + "wiki": str(wiki_dir), + "count": len(recent), + "total_matching": len(entries), + "limit": limit, + "include_captures": include_captures, + "entries": recent, + "privacy_note": "Memory bodies and raw capture contents are not included; this is lifecycle metadata from wiki/log.md.", + "next_actions": [ + { + "label": "Inspect memory review queue", + "command": "link memory-inbox", + "reason": "Review pending, stale, expired, or underspecified memories before relying on them.", + }, + { + "label": "Explain a changed memory", + "command": "link explain-memory ", + "reason": "Open provenance, review status, graph links, and matching log entries for one memory.", + }, + ], + } + + +def _memory_log_entry(entry: Mapping[str, object]) -> dict[str, object]: + operation = str(entry.get("operation") or "").strip() + details = [ + str(item).strip() + for item in entry.get("details", []) + if str(item).strip() + ] if isinstance(entry.get("details"), list) else [] + memory_paths = _memory_paths(entry, details) + category = "capture" if operation in CAPTURE_OPERATIONS else "memory" + details = _safe_details(operation, details) + return { + "timestamp": str(entry.get("timestamp") or ""), + "operation": operation, + "category": category, + "description": str(entry.get("description") or ""), + "memory_paths": memory_paths, + "details": details, + "summary": _summary(operation, str(entry.get("description") or ""), memory_paths), + } + + +def _memory_paths(entry: Mapping[str, object], details: list[str]) -> list[str]: + text = "\n".join([str(entry.get("description") or ""), *details]) + paths: list[str] = [] + for match in MEMORY_PATH_RE.finditer(text): + path = f"wiki/memories/{match.group(1)}" + if path not in paths: + paths.append(path) + return paths + + +def _safe_details(operation: str, details: list[str]) -> list[str]: + if operation in CAPTURE_OPERATIONS: + return [ + detail for detail in details + if not detail.lower().startswith("source input:") + ][:8] + prefixes = ( + "created: memories/", + "updated: memories/", + "reviewed: memories/", + "memory: wiki/memories/", + "memory: memories/", + "previous ", + "new ", + "type:", + "scope:", + "project:", + "reason:", + "review ", + "update count:", + "title:", + "deleted memory page", + ) + safe = [ + detail for detail in details + if detail.lower().startswith(prefixes) or "memories/" in detail.lower() + ] + return safe[:8] + + +def _summary(operation: str, description: str, memory_paths: list[str]) -> str: + target = memory_paths[0] if memory_paths else description + if operation == "remember": + return f"Created memory: {target}" + if operation == "accept-capture": + return f"Accepted capture proposal into memory: {target}" + if operation == "update-memory": + return f"Updated memory and returned it to review: {target}" + if operation == "review-memory": + return f"Marked memory reviewed: {target}" + if operation == "archive-memory": + return f"Archived memory: {target}" + if operation == "restore-memory": + return f"Restored memory: {target}" + if operation == "forget-memory": + return f"Permanently forgot memory: {target}" + if operation == "capture-session": + return f"Captured proposal-only notes: {description}" + if operation == "redact-capture": + return f"Redacted a raw capture: {description}" + if operation == "delete-capture": + return f"Deleted a raw capture: {description}" + return description diff --git a/mcp_package/link_core/web_layout.py b/mcp_package/link_core/web_layout.py index 46a915a..9e62668 100644 --- a/mcp_package/link_core/web_layout.py +++ b/mcp_package/link_core/web_layout.py @@ -111,6 +111,7 @@ def render_header_html() -> str: inbox captures profile + memory log log all pages
diff --git a/mcp_package/link_core/web_memory_pages.py b/mcp_package/link_core/web_memory_pages.py index 33609a8..ab40774 100644 --- a/mcp_package/link_core/web_memory_pages.py +++ b/mcp_package/link_core/web_memory_pages.py @@ -365,6 +365,29 @@ def render_inbox_page( return layout("Memory Review Inbox", body) +def render_memory_log_page(log_payload: Mapping[str, object], *, layout: PageLayout) -> str: + entries = _dict_list(log_payload.get("entries")) + if entries: + rows = "".join(_render_memory_log_item(entry) for entry in entries) + content = f"
    {rows}
" + else: + content = ( + "

No memory lifecycle events yet.

" + '

Create memory proposals

' + ) + body = ( + '' + '

Memory Changelog

' + '
' + '

A privacy-safe timeline of memory creates, updates, reviews, archives, restores, forgets, and capture accepts.

' + f'{render_stat_grid([(log_payload.get("count", 0), "shown"), (log_payload.get("total_matching", 0), "matching")])}' + f'

{html.escape(str(log_payload.get("privacy_note") or ""))}

' + f'{content}' + '
' + ) + return layout("Memory Changelog", body) + + def render_memory_explanation_page( explanation: Mapping[str, object], *, @@ -431,6 +454,30 @@ def render_memory_explanation_page( return layout(f"Explain: {title}", body) +def _render_memory_log_item(entry: Mapping[str, object]) -> str: + paths = _list(entry.get("memory_paths")) + path_html = "" + if paths: + path_html = "
Memories: " + html.escape(", ".join(str(path) for path in paths)) + "
" + details = _list(entry.get("details")) + detail_html = "" + if details: + detail_html = "
    " + "".join( + f"
  • {html.escape(str(detail))}
  • " + for detail in details[:4] + ) + "
" + return ( + "
  • " + f"{html.escape(str(entry.get('operation') or 'event'))}" + f"
    {html.escape(str(entry.get('timestamp') or ''))} · {html.escape(str(entry.get('category') or 'memory'))}
    " + f"

    {html.escape(str(entry.get('description') or ''))}

    " + f"{html.escape(str(entry.get('summary') or ''))}" + f"{path_html}" + f"{detail_html}" + "
  • " + ) + + def _render_inbox_item(item: Mapping[str, object], *, page_href: PageHref) -> str: name = str(item.get("name") or "") summary = item.get("tldr") or item.get("snippet") or "" diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 36a8496..d22b993 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -162,6 +162,9 @@ append_log as _core_append_log, utc_timestamp as _core_utc_timestamp, ) +from link_core.memory_log import ( + memory_log_payload as _core_memory_log_payload, +) from link_core.operations import ( operation_report as _core_operation_report, ) @@ -314,6 +317,14 @@ def _memory_inbox(limit: int = 20, include_archived: bool = False, project: str ) +def _memory_log(limit: int = 50, include_captures: bool = True) -> dict[str, object]: + return _core_memory_log_payload( + WIKI_DIR, + limit=_parse_limit(limit, default=50), + include_captures=include_captures, + ) + + def _memory_explanation(identifier: str) -> dict[str, object]: return _core_memory_explanation( WIKI_DIR, @@ -1075,6 +1086,18 @@ def memory_inbox(limit: int = 20, include_archived: bool = False, project: str = return json.dumps(_memory_inbox(limit=limit, include_archived=include_archived, project=project), ensure_ascii=False) +@mcp.tool() +def memory_log(limit: int = 50, include_captures: bool = True) -> str: + """List recent memory lifecycle changes. + + Use this when the user asks what Link remembered, updated, reviewed, + archived, restored, forgot, or accepted from captures recently. The result + is metadata from wiki/log.md and does not include raw source or memory + bodies. + """ + return json.dumps(_memory_log(limit=limit, include_captures=include_captures), ensure_ascii=False) + + @mcp.tool() def review_memory(identifier: str, note: str = "") -> str: """Mark a memory as reviewed after user confirmation.""" diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 2298a70..30285b5 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -30,6 +30,7 @@ "init", "memory-audit", "memory-inbox", + "memory-log", "migrate", "next", "operations", @@ -77,6 +78,7 @@ "memory_audit", "memory_brief", "memory_inbox", + "memory_log", "memory_profile", "migrate_wiki", "propose_memories", diff --git a/serve.py b/serve.py index 9ccd530..d0f567f 100644 --- a/serve.py +++ b/serve.py @@ -53,6 +53,9 @@ append_log as _core_append_log, utc_timestamp as _core_utc_timestamp, ) +from link_core.memory_log import ( + memory_log_payload as _core_memory_log_payload, +) from link_core.markdown import ( markdown_to_html as _core_markdown_to_html, ) @@ -85,6 +88,7 @@ render_captures_page as _core_render_captures_page, render_inbox_page as _core_render_inbox_page, render_memory_explanation_page as _core_render_memory_explanation_page, + render_memory_log_page as _core_render_memory_log_page, render_memory_audit_page as _core_render_memory_audit_page, render_memory_dashboard_page as _core_render_memory_dashboard_page, render_profile_page as _core_render_profile_page, @@ -765,6 +769,14 @@ def _memory_audit(limit: int = 10, project: str | None = None) -> dict[str, obje return payload +def _memory_log(limit: int = 50, include_captures: bool = True) -> dict[str, object]: + return _core_memory_log_payload( + WIKI_DIR, + limit=max(1, min(limit, 200)), + include_captures=include_captures, + ) + + def _json_for_script(data) -> str: """Serialize JSON for direct embedding inside a + + + + +
    +
    + Team review +

    Local agent memory without a new cloud data boundary.

    +

    A practical checklist for engineering leads and security reviewers evaluating Link for a small team, repo, or design partner pilot.

    +
    +
    + +
    +
    + +
    +

    Deployment Model

    +

    Link is designed as a local personal or repo-local memory layer. Each developer runs the CLI and MCP server on their own machine. The web viewer is optional and only serves the local UI.

    +
    + No server dependency + link serve is only the human web viewer. CLI and MCP access work directly against local Markdown files when the viewer is closed. +
    +
    brew install gowtham0992/link/link
    +link init ~/link
    +link health ~/link
    +link connect codex ~/link
    + +

    Data Boundaries

    +
      +
    • raw/ contains private source material and is gitignored by default.
    • +
    • wiki/ contains structured Markdown pages, memories, logs, and backlinks.
    • +
    • link-mcp talks over stdio to the local agent client. It does not require serve.py.
    • +
    • The installed product has no telemetry, hosted backend, or outbound API calls.
    • +
    • Secret-looking values are scanned before capture, ingest, Obsidian import, and doctor checks.
    • +
    +
    link ingest-status ~/link
    +link doctor ~/link
    +link validate ~/link
    + +

    Memory Approval Gates

    +

    Agents can propose memories, but durable memory should be explicit and reviewable. Link keeps memory as Markdown with type, scope, project, source, review status, optional review dates, and optional expiry dates.

    +
    link propose-memories raw/notes.md ~/link
    +link memory-inbox ~/link
    +link review-memory memory-name ~/link
    +link archive-memory memory-name ~/link --reason stale
    +

    For temporary context, use expires_at. For decisions that should be re-confirmed, use review_after.

    + +

    Team Sharing Pattern

    +

    The safest early team workflow is Git-backed sharing of reviewed wiki pages. Keep raw sources local unless the team explicitly decides to share them.

    +
    link team-sync ~/link --remote git@example.com:team/link-memory.git
    +link compliance-export ~/link --output link-audit.json
    +link backup ~/link
    +

    link team-sync is read-only. It checks Git state, raw-source protection, review readiness, and prints paste-safe commands instead of pushing data for you.

    + +

    Audit Packet

    +

    link compliance-export creates a redacted JSON packet for review. It includes readiness, validation status, memory review counts, operation markers, recent audit log metadata, and safe next actions. Raw source contents and memory bodies are excluded.

    +
    link compliance-export ~/link --output link-audit.json
    +link wins ~/link
    +link memory-log ~/link
    + +

    Current Limits

    +
      +
    • Link is local-first and single-user by default. It is not an SSO-backed team server.
    • +
    • The local web viewer has no authentication and should not be exposed beyond loopback.
    • +
    • Git sharing is intentionally manual so teams see exactly what is being committed.
    • +
    • Access control is currently based on local files, project filters, and review workflow, not centralized RBAC.
    • +
    + +

    Security Review Checklist

    +
      +
    1. Run link health ~/link and verify readiness is green.
    2. +
    3. Run link doctor ~/link and resolve secret or validation warnings.
    4. +
    5. Run link compliance-export ~/link --output link-audit.json.
    6. +
    7. Confirm raw/, backups, caches, and local MCP Python markers are ignored by Git.
    8. +
    9. Review wiki/log.md, link memory-log ~/link, and link wins ~/link.
    10. +
    11. Only share reviewed wiki/ pages that your team intends to share.
    12. +
    +
    +
    +
    + + + + From bd5d4ca7938a84f58dce6cbd838b95db5772553c Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 02:32:00 -0600 Subject: [PATCH 19/35] Add read-only wiki snapshot export --- CHANGELOG.md | 1 + README.md | 10 + docs/cli.html | 4 + link.py | 32 +++ mcp_package/link_core/cli_parser.py | 19 ++ mcp_package/link_core/snapshot.py | 366 ++++++++++++++++++++++++++++ scripts/check_tool_contract.py | 1 + tests/test_cli_parser_core.py | 56 +++++ tests/test_snapshot_core.py | 126 ++++++++++ 9 files changed, 615 insertions(+) create mode 100644 mcp_package/link_core/snapshot.py create mode 100644 tests/test_snapshot_core.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 780defc..724fe4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI - Added `link compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review. - Added `link team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically. - Added `link share ` to print a local viewer permalink and agent prompt for a specific Link page. +- Added `link snapshot` to export a static, read-only HTML snapshot for demos or reviews while excluding raw sources, captures, live state, and memory pages by default. - Added `link memory-log`, MCP `memory_log`, `/memory-log`, and `/api/memory-log` for recent memory lifecycle changes without exposing raw source or memory bodies. - Added `link wins`, MCP `memory_wins`, `/wins`, and `/api/wins` for local, non-telemetry proof signals about what Link memory is carrying. - Added a team security review docs page covering local deployment, data boundaries, memory approval gates, Git sharing, audit exports, and current limits. diff --git a/README.md b/README.md index 4e5548d..c9136bb 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,16 @@ memory, title, alias, or search phrase into a local viewer URL: link share "Prefer local memory" ~/link ``` +For a static, read-only review packet, `link snapshot` exports rendered wiki +HTML without `raw/`, captures, operation markers, live MCP state, or memory pages +by default. It blocks export if wiki pages contain secret-looking values unless +you explicitly override it. + +```bash +link snapshot ~/link --output link-snapshot +link snapshot ~/link --output link-snapshot --include-memories --force +``` + ## Agent Contract Agents should use Link in this order: diff --git a/docs/cli.html b/docs/cli.html index 759e10b..4ea1d0d 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -69,6 +69,7 @@

    Daily Loop

    link remember "User prefers feature branches for Link work." --type preference --scope project --project link --review-after 2026-08-01 link remember "Temporary launch branch is release/one-off." --type project --project link --expires-at 2026-09-01 link share "Prefer local memory" +link snapshot ~/link --output link-snapshot link brief "working on Link release" --project link link query "what should I know before changing the MCP tools?" --budget small --project link link validate @@ -99,6 +100,7 @@

    Maintenance

    link memory-audit link compliance-export --output link-audit.json link team-sync ~/link +link snapshot ~/link --output link-snapshot link operations link benchmark "agent memory" link rebuild-index @@ -107,6 +109,7 @@

    Maintenance

    link verify-mcp

    Use link backup before broad repair work. Use link benchmark when a wiki starts to feel slow. link status --validate and link benchmark both show persistent-cache reuse so you can tell whether Link is rereading every page or reusing unchanged records.

    Use link team-sync before sharing Link through Git. It is read-only: it checks Git state, verifies that raw/ is protected, and prints paste-safe setup/sync commands without pushing private source material for you.

    +

    Use link snapshot when you need a static, read-only HTML export for a teammate, maintainer, security reviewer, or demo. It excludes raw/, captures, local operation state, and memory pages by default.

    Use link connect <agent> when an agent already has Link instructions but still needs MCP wiring. It previews the config before writing.

    link connect codex ~/link
     link connect codex ~/link --write
    @@ -131,6 +134,7 @@ 

    All Commands

    link compliance-export [dir] [--output audit.json] [--project slug] link team-sync [dir] [--remote git-url] link share <page-or-memory> [dir] [--port 3000] +link snapshot [dir] [--output link-snapshot] [--include-memories] [--force] link ingest-status link import-obsidian <vault> [dir] [--dry-run] [--overwrite] link remember "text" [--project slug] [--review-after YYYY-MM-DD] [--expires-at YYYY-MM-DD] diff --git a/link.py b/link.py index 96c5d62..daa7a15 100644 --- a/link.py +++ b/link.py @@ -15,6 +15,7 @@ python link.py compliance-export [target] python link.py team-sync [target] python link.py share [target] + python link.py snapshot [target] python link.py doctor [target] python link.py migrate [target] python link.py validate [target] @@ -152,6 +153,10 @@ render_share_text as _core_render_share_text, share_page_payload as _core_share_page_payload, ) +from link_core.snapshot import ( + export_snapshot as _core_export_snapshot, + render_snapshot_text as _core_render_snapshot_text, +) from link_core.benchmark import ( build_benchmark_payload as _core_build_benchmark_payload, render_benchmark_text as _core_render_benchmark_text, @@ -824,6 +829,32 @@ def share(target: Path, identifier: str, port: int = 3000, host: str = "127.0.0. return _emit_json_or_text(payload, json_output, _core_render_share_text, json_code=0 if payload.get("found") else 1) +def snapshot( + target: Path, + output: str = "link-snapshot", + include_memories: bool = False, + allow_sensitive: bool = False, + force: bool = False, + title: str = "Link", + json_output: bool = False, +) -> int: + wiki_dir = _resolve_wiki_dir(target) + payload = _core_export_snapshot( + wiki_dir, + Path(output), + include_memories=include_memories, + allow_sensitive=allow_sensitive, + force=force, + title=title, + ) + return _emit_json_or_text( + payload, + json_output, + _core_render_snapshot_text, + json_code=0 if payload.get("created") else 1, + ) + + def ingest_status(target: Path, json_output: bool = False) -> int: target = target.expanduser().resolve() status = _collect_ingest_status(target) @@ -1942,6 +1973,7 @@ def main(argv: list[str] | None = None) -> int: "compliance-export": compliance_export, "team-sync": team_sync, "share": share, + "snapshot": snapshot, "doctor": doctor, "migrate": migrate, "validate": validate, diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index 1d3da4c..fdb7303 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -90,6 +90,15 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum share_cmd.add_argument("--host", default="127.0.0.1", help="local viewer host to include in the URL") share_cmd.add_argument("--json", action="store_true", help="print machine-readable share details") + snapshot_cmd = sub.add_parser("snapshot", help="export a static read-only HTML snapshot") + snapshot_cmd.add_argument("target", nargs="?", default=".") + snapshot_cmd.add_argument("--output", default="link-snapshot", help="directory to write the snapshot into") + snapshot_cmd.add_argument("--include-memories", action="store_true", help="include memory pages intentionally") + snapshot_cmd.add_argument("--allow-sensitive", action="store_true", help="export even if wiki pages contain secret-looking values") + snapshot_cmd.add_argument("--force", action="store_true", help="replace a non-empty output directory") + snapshot_cmd.add_argument("--title", default="Link", help="snapshot title") + snapshot_cmd.add_argument("--json", action="store_true", help="print machine-readable snapshot status") + doctor_cmd = sub.add_parser("doctor", help="check a Link wiki for common health issues") doctor_cmd.add_argument("target", nargs="?", default=".") doctor_cmd.add_argument("--fix", action="store_true", help="repair safe structural and backlink issues") @@ -358,6 +367,16 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: host=args.host, json_output=args.json, ) + if command == "snapshot": + return handlers["snapshot"]( + Path(args.target), + output=args.output, + include_memories=args.include_memories, + allow_sensitive=args.allow_sensitive, + force=args.force, + title=args.title, + json_output=args.json, + ) if command == "doctor": return handlers["doctor"](Path(args.target), fix=args.fix) if command == "migrate": diff --git a/mcp_package/link_core/snapshot.py b/mcp_package/link_core/snapshot.py new file mode 100644 index 0000000..09aa400 --- /dev/null +++ b/mcp_package/link_core/snapshot.py @@ -0,0 +1,366 @@ +"""Read-only static snapshot export for Link wikis.""" +from __future__ import annotations + +import html +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping +from urllib.parse import quote + +from .files import atomic_write_json, atomic_write_text +from .markdown import markdown_to_html +from .search import close_wiki_cache +from .security import find_sensitive_values +from .wiki import build_wiki_cache + + +SNAPSHOT_SCHEMA = "link-snapshot-v1" +SNAPSHOT_CSS = """ +:root { + color-scheme: light; + --bg: #fbf7df; + --ink: #17130d; + --muted: #5f5a50; + --line: #1b1711; + --panel: #fffdf1; + --accent: #ffd84d; +} +* { box-sizing: border-box; } +body { + margin: 0; + background: + linear-gradient(rgba(0,0,0,.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(0,0,0,.035) 1px, transparent 1px), + var(--bg); + background-size: 24px 24px; + color: var(--ink); + font: 16px/1.55 Georgia, "Times New Roman", serif; +} +a { color: #064fb0; text-decoration-thickness: 1px; text-underline-offset: 2px; } +header, main, footer { max-width: 1040px; margin: 0 auto; padding: 24px; } +header { border-bottom: 3px solid var(--line); background: rgba(255,253,241,.88); } +.brand { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } +.brand h1 { margin: 0; font-size: clamp(2.2rem, 6vw, 5rem); line-height: .95; } +.brand span { color: var(--muted); font-size: 1.05rem; } +.notice, .card { + background: var(--panel); + border: 2px solid var(--line); + box-shadow: 6px 6px 0 var(--line); + padding: 18px; + margin: 18px 0; +} +.notice { border-color: #8c6a00; box-shadow: 5px 5px 0 #8c6a00; } +.meta { color: var(--muted); font-size: .94rem; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; } +.page-list { list-style: none; margin: 0; padding: 0; } +.page-list li { padding: 10px 0; border-bottom: 1px solid rgba(23,19,13,.18); } +.page-list small { display: block; color: var(--muted); } +article { + background: rgba(255,253,241,.76); + border-left: 4px solid var(--line); + padding: 8px 0 8px 20px; +} +article h1, article h2, article h3 { line-height: 1.15; } +article code, pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: rgba(0,0,0,.075); +} +article code { padding: 2px 4px; } +pre { overflow: auto; padding: 14px; } +blockquote { margin-left: 0; padding-left: 14px; border-left: 3px solid #aaa; color: #3f3a32; } +table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; } +th, td { border: 1px solid rgba(23,19,13,.22); padding: 6px 8px; text-align: left; } +footer { color: var(--muted); border-top: 2px solid rgba(23,19,13,.18); } +""".strip() + "\n" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _is_relative_to(child: Path, parent: Path) -> bool: + try: + child.relative_to(parent) + return True + except ValueError: + return False + + +def _page_filename(name: object) -> str: + return quote(str(name).strip(), safe="") + ".html" + + +def _category_title(category: str) -> str: + return { + "concepts": "Concepts", + "entities": "Entities", + "sources": "Sources", + "comparisons": "Comparisons", + "explorations": "Explorations", + "memories": "Memories", + "root": "Other Pages", + }.get(category, category.replace("-", " ").title()) + + +def _include_page(page: Mapping[str, Any], *, include_memories: bool) -> bool: + name = str(page.get("name") or "").lower() + category = str(page.get("category") or "") + if name in {"index", "log"}: + return False + if category == "memories" and not include_memories: + return False + return True + + +def _metadata_line(page: Mapping[str, Any]) -> str: + parts = [ + str(page.get("category") or ""), + str(page.get("type") or ""), + f"{page.get('source_count')} sources" if str(page.get("source_count") or "").strip() else "", + f"updated {page.get('date_updated')}" if str(page.get("date_updated") or "").strip() else "", + ] + return " · ".join(part for part in parts if part) + + +def _html_shell(title: str, body: str, *, root_prefix: str = "") -> str: + safe_title = html.escape(title) + return ( + "\n" + "\n" + "\n" + " \n" + " \n" + f" {safe_title}\n" + f" \n" + "\n" + "\n" + f"{body}\n" + "
    Generated by Link snapshot. Static, read-only, and local-first.
    \n" + "\n" + "\n" + ) + + +def _index_html( + *, + title: str, + pages: list[Mapping[str, Any]], + page_href: Mapping[str, str], + created_at: str, + include_memories: bool, + excluded_counts: Mapping[str, int], +) -> str: + grouped: dict[str, list[Mapping[str, Any]]] = {} + for page in pages: + grouped.setdefault(str(page.get("category") or "root"), []).append(page) + sections: list[str] = [] + for category in sorted(grouped): + items = [] + for page in sorted(grouped[category], key=lambda item: str(item.get("title") or item.get("name")).lower()): + name = str(page.get("name") or "") + href = page_href.get(name.lower(), "#") + summary = str(page.get("tldr") or _metadata_line(page)) + items.append( + "
  • " + f"{html.escape(str(page.get('title') or name))}" + f"{html.escape(summary)}" + "
  • " + ) + sections.append( + "
    " + f"

    {html.escape(_category_title(category))}

    " + f"
      {''.join(items)}
    " + "
    " + ) + memory_note = "included" if include_memories else "excluded by default" + body = ( + "
    " + f"

    {html.escape(title)}

    Link read-only snapshot
    " + f"

    Created {html.escape(created_at)} · {len(pages)} exported pages · memories {memory_note}

    " + "
    " + "
    " + "
    " + "Safe sharing note: this snapshot contains rendered wiki pages only. " + "It does not include raw sources, raw captures, live MCP state, operation markers, or the local web server. " + "Secret-looking wiki contents are blocked before export unless explicitly overridden." + "
    " + f"

    Excluded: {int(excluded_counts.get('memories', 0))} memories, " + f"{int(excluded_counts.get('root', 0))} generated root pages.

    " + f"
    {''.join(sections)}
    " + "
    " + ) + return _html_shell(title, body) + + +def _page_html(page: Mapping[str, Any], body_markdown: str, page_href: Mapping[str, str]) -> str: + name = str(page.get("name") or "") + title = str(page.get("title") or name) + + def href_for(target: str) -> str: + href = page_href.get(target.strip().lower()) + if not href: + return "#not-in-snapshot" + return href.removeprefix("pages/") + + rendered = markdown_to_html(body_markdown, page_href=href_for) + body = ( + "
    " + f"

    Link snapshot / {html.escape(str(page.get('category') or 'page'))}

    " + f"

    {html.escape(title)}

    {html.escape(_metadata_line(page))}
    " + "
    " + "
    " + f"
    {rendered}
    " + "
    " + ) + return _html_shell(title, body, root_prefix="../") + + +def export_snapshot( + wiki_dir: Path, + output_dir: Path, + *, + include_memories: bool = False, + allow_sensitive: bool = False, + force: bool = False, + title: str = "Link", + cache: dict[str, Any] | None = None, +) -> dict[str, object]: + """Export a read-only HTML snapshot of safe wiki pages.""" + wiki_dir = wiki_dir.expanduser().resolve() + output_dir = output_dir.expanduser().resolve() + if not wiki_dir.exists(): + return {"created": False, "error": f"missing wiki directory: {wiki_dir}", "output": str(output_dir)} + if _is_relative_to(output_dir, wiki_dir): + return { + "created": False, + "error": "snapshot output cannot be inside wiki/", + "output": str(output_dir), + } + if output_dir.exists() and any(output_dir.iterdir()): + if not force: + return { + "created": False, + "error": "snapshot output directory is not empty; choose another path or use --force", + "output": str(output_dir), + } + shutil.rmtree(output_dir) + + sensitive_values: list[str] = [] + sensitive_read_errors: list[str] = [] + if not allow_sensitive: + sensitive_values, sensitive_read_errors = find_sensitive_values( + wiki_dir, + skip_dirs={".link-operations", ".link-cache", ".link-backups"}, + skip_suffixes={".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".zip", ".gz"}, + ) + if sensitive_values or sensitive_read_errors: + return { + "created": False, + "error": "wiki contains secret-looking values or unreadable files; run link doctor before exporting", + "output": str(output_dir), + "sensitive_values": sensitive_values, + "read_errors": sensitive_read_errors, + } + + owns_cache = cache is None + resolved_cache = cache or build_wiki_cache(wiki_dir) + try: + all_pages = [page for page in list(resolved_cache.get("pages") or []) if isinstance(page, Mapping)] + included_pages = [ + page for page in all_pages + if _include_page(page, include_memories=include_memories) + ] + excluded_counts = { + "memories": sum(1 for page in all_pages if str(page.get("category") or "") == "memories" and page not in included_pages), + "root": sum(1 for page in all_pages if str(page.get("name") or "").lower() in {"index", "log"}), + } + page_href = { + str(page.get("name") or "").lower(): f"pages/{_page_filename(page.get('name'))}" + for page in included_pages + } + body_index = resolved_cache.get("body_index") if isinstance(resolved_cache.get("body_index"), dict) else {} + output_dir.mkdir(parents=True, exist_ok=True) + pages_dir = output_dir / "pages" + pages_dir.mkdir(parents=True, exist_ok=True) + assets_dir = output_dir / "assets" + assets_dir.mkdir(parents=True, exist_ok=True) + created_at = _utc_now() + + for page in included_pages: + name = str(page.get("name") or "") + body = str(body_index.get(name.lower()) or "") + atomic_write_text(pages_dir / _page_filename(name), _page_html(page, body, page_href)) + + atomic_write_text( + output_dir / "index.html", + _index_html( + title=title, + pages=included_pages, + page_href=page_href, + created_at=created_at, + include_memories=include_memories, + excluded_counts=excluded_counts, + ), + ) + atomic_write_text(assets_dir / "snapshot.css", SNAPSHOT_CSS) + public_manifest = { + "schema": SNAPSHOT_SCHEMA, + "created": True, + "created_at": created_at, + "page_count": len(included_pages), + "include_memories": include_memories, + "excluded_counts": excluded_counts, + "privacy_note": ( + "Snapshot includes rendered wiki pages only. Raw sources, raw captures, operation markers, " + "and live MCP state are not exported." + ), + } + atomic_write_json(output_dir / "snapshot.json", public_manifest) + return public_manifest | { + "wiki": str(wiki_dir), + "output": str(output_dir), + "index": str(output_dir / "index.html"), + } + finally: + if owns_cache: + close_wiki_cache(resolved_cache) + + +def render_snapshot_text(payload: Mapping[str, object]) -> tuple[int, str]: + """Render snapshot export status for CLI users.""" + if not payload.get("created"): + lines = [ + "Link snapshot: not created", + "", + f"Output: {payload.get('output') or ''}", + f"Reason: {payload.get('error') or 'unknown error'}", + ] + sensitive = payload.get("sensitive_values") + if isinstance(sensitive, list) and sensitive: + lines.append("") + lines.append("Secret-looking wiki contents:") + lines.extend(f"- {item}" for item in sensitive[:8]) + read_errors = payload.get("read_errors") + if isinstance(read_errors, list) and read_errors: + lines.append("") + lines.append("Unreadable files:") + lines.extend(f"- {item}" for item in read_errors[:8]) + lines.extend(["", "Next:", " link doctor"]) + return 1, "\n".join(lines) + + lines = [ + "Link snapshot created", + "", + f"Output: {payload.get('output')}", + f"Open: {payload.get('index')}", + f"Pages: {payload.get('page_count')}", + f"Memories included: {'yes' if payload.get('include_memories') else 'no'}", + "", + "Safe sharing note:", + f" {payload.get('privacy_note')}", + ] + if not payload.get("include_memories"): + lines.extend(["", "To include memory pages intentionally, rerun with --include-memories."]) + return 0, "\n".join(lines) diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index e344ce6..50e50a9 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -49,6 +49,7 @@ "review-memory", "serve", "share", + "snapshot", "status", "team-sync", "try", diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index fb5c638..5c19f03 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -105,6 +105,31 @@ def test_share_command_options(self): self.assertEqual(args.host, "localhost") self.assertTrue(args.json) + def test_snapshot_command_options(self): + parser = build_cli_parser() + + args = parser.parse_args([ + "snapshot", + "/tmp/link", + "--output", + "/tmp/link-snapshot", + "--include-memories", + "--allow-sensitive", + "--force", + "--title", + "Team Link", + "--json", + ]) + + self.assertEqual(args.command, "snapshot") + self.assertEqual(args.target, "/tmp/link") + self.assertEqual(args.output, "/tmp/link-snapshot") + self.assertTrue(args.include_memories) + self.assertTrue(args.allow_sensitive) + self.assertTrue(args.force) + self.assertEqual(args.title, "Team Link") + self.assertTrue(args.json) + def test_memory_log_command_options(self): parser = build_cli_parser() @@ -367,6 +392,37 @@ def share_handler(target, identifier, **kwargs): self.assertEqual(calls[0][2]["host"], "localhost") self.assertTrue(calls[0][2]["json_output"]) + def test_dispatch_routes_snapshot_arguments(self): + parser = build_cli_parser() + args = parser.parse_args([ + "snapshot", + "/tmp/link", + "--output", + "/tmp/snapshot", + "--include-memories", + "--allow-sensitive", + "--force", + "--title", + "Team Link", + "--json", + ]) + calls = [] + + def snapshot_handler(target, **kwargs): + calls.append((target, kwargs)) + return 3 + + code = dispatch_cli_command(args, {"snapshot": snapshot_handler}) + + self.assertEqual(code, 3) + self.assertEqual(calls[0][0], Path("/tmp/link")) + self.assertEqual(calls[0][1]["output"], "/tmp/snapshot") + self.assertTrue(calls[0][1]["include_memories"]) + self.assertTrue(calls[0][1]["allow_sensitive"]) + self.assertTrue(calls[0][1]["force"]) + self.assertEqual(calls[0][1]["title"], "Team Link") + self.assertTrue(calls[0][1]["json_output"]) + def test_dispatch_routes_import_obsidian_arguments(self): parser = build_cli_parser() args = parser.parse_args([ diff --git a/tests/test_snapshot_core.py b/tests/test_snapshot_core.py new file mode 100644 index 0000000..223b499 --- /dev/null +++ b/tests/test_snapshot_core.py @@ -0,0 +1,126 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "mcp_package")) + +from link_core.snapshot import export_snapshot, render_snapshot_text # noqa: E402 + + +def _write_page(path: Path, title: str, page_type: str, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join([ + "---", + f"title: {title}", + f"type: {page_type}", + "tags: [test]", + "---", + "", + f"# {title}", + "", + body, + "", + ]), + encoding="utf-8", + ) + + +class SnapshotCoreTests(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp(prefix="link-snapshot-test-")) + self.wiki = self.root / "wiki" + for name in ("concepts", "entities", "sources", "memories", "comparisons", "explorations"): + (self.wiki / name).mkdir(parents=True, exist_ok=True) + (self.wiki / "index.md").write_text("# Index\n\n- [[agent-memory]]\n", encoding="utf-8") + (self.wiki / "log.md").write_text("# Log\n\n", encoding="utf-8") + (self.wiki / "_backlinks.json").write_text('{"backlinks": {}, "forward": {}}\n', encoding="utf-8") + _write_page( + self.wiki / "concepts/agent-memory.md", + "Agent memory", + "concept", + "> **TLDR:** Memory that agents can inspect.\n\nLinks to [[source-note]] and [[prefer-local-memory]].", + ) + _write_page( + self.wiki / "sources/source-note.md", + "Source note", + "source", + "> **TLDR:** A source note.\n\n## Summary\n\nSource-backed context.\n\n## Raw Source\n\n`raw/source-note.md`", + ) + _write_page( + self.wiki / "memories/prefer-local-memory.md", + "Prefer local memory", + "memory", + "> **TLDR:** User prefers local memory.\n\nPrivate preference.", + ) + + def test_export_snapshot_excludes_memories_and_raw_by_default(self): + output = self.root / "snapshot" + + payload = export_snapshot(self.wiki, output) + + self.assertTrue(payload["created"]) + self.assertEqual(payload["schema"], "link-snapshot-v1") + self.assertFalse(payload["include_memories"]) + self.assertEqual(payload["page_count"], 2) + self.assertTrue((output / "index.html").exists()) + self.assertTrue((output / "pages/agent-memory.html").exists()) + self.assertFalse((output / "pages/prefer-local-memory.html").exists()) + self.assertFalse((output / "raw").exists()) + page_html = (output / "pages/agent-memory.html").read_text(encoding="utf-8") + self.assertIn('href="source-note.html"', page_html) + self.assertNotIn('href="pages/source-note.html"', page_html) + manifest = json.loads((output / "snapshot.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["page_count"], 2) + self.assertNotIn("wiki", manifest) + self.assertNotIn("output", manifest) + self.assertNotIn(str(self.root), json.dumps(manifest)) + + def test_export_snapshot_can_include_memories_intentionally(self): + output = self.root / "snapshot" + + payload = export_snapshot(self.wiki, output, include_memories=True) + + self.assertTrue(payload["created"]) + self.assertTrue(payload["include_memories"]) + self.assertEqual(payload["page_count"], 3) + self.assertTrue((output / "pages/prefer-local-memory.html").exists()) + + def test_export_snapshot_blocks_secret_looking_wiki_values(self): + fake_key = "AKIA" + ("A" * 16) + _write_page( + self.wiki / "concepts/leak.md", + "Leak", + "concept", + f"> **TLDR:** Leaked key.\n\nkey = {fake_key}", + ) + + payload = export_snapshot(self.wiki, self.root / "snapshot") + + self.assertFalse(payload["created"]) + self.assertIn("secret-looking", payload["error"]) + self.assertTrue(payload["sensitive_values"]) + code, text = render_snapshot_text(payload) + self.assertEqual(code, 1) + self.assertIn("Secret-looking wiki contents", text) + + def test_export_snapshot_refuses_non_empty_output_without_force(self): + output = self.root / "snapshot" + output.mkdir() + (output / "old.html").write_text("old", encoding="utf-8") + + blocked = export_snapshot(self.wiki, output) + created = export_snapshot(self.wiki, output, force=True) + + self.assertFalse(blocked["created"]) + self.assertIn("not empty", blocked["error"]) + self.assertTrue(created["created"]) + self.assertFalse((output / "old.html").exists()) + + +if __name__ == "__main__": + unittest.main() From ae803148cbbaf7a1764049d62c0dbf9e2a46fc3a Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 03:16:55 -0600 Subject: [PATCH 20/35] Tighten team sync memory sharing gate --- CHANGELOG.md | 1 + README.md | 4 +- docs/cli.html | 2 +- docs/team-security.html | 2 +- mcp_package/link_core/team_sync.py | 66 +++++++++++++++++++++++++++++- tests/test_team_sync_core.py | 65 +++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 724fe4f..0af45ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ### Changed - Broadened local secret detection for common modern provider tokens and credentials before capture, ingest, Obsidian import, and doctor scans. +- Tightened `link team-sync` readiness so unreviewed memories or active user-scoped memories block "ready" status before Git sharing. - Broadened Windows CI from a small portability subset to most non-installer/non-server tests. - Clarified that the Homebrew formula lives in the separate `gowtham0992/homebrew-link` tap. - Tightened security reporting guidance to prefer private maintainer contact before public GitHub issues. diff --git a/README.md b/README.md index c9136bb..0292359 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,9 @@ prompts without tracking user behavior. For Git-backed team memory, `link team-sync ~/link` checks whether the workspace is ready to share reviewed `wiki/` pages while keeping `raw/`, caches, backups, -and local MCP Python markers private by default. +and local MCP Python markers private by default. It also blocks "ready" status +when the memory inbox is not clear or active user-scoped memories would be +included by a broad `git add wiki`. ```bash link team-sync ~/link --remote git@example.com:team/link-memory.git diff --git a/docs/cli.html b/docs/cli.html index 4ea1d0d..66ffef5 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -108,7 +108,7 @@

    Maintenance

    link validate link verify-mcp

    Use link backup before broad repair work. Use link benchmark when a wiki starts to feel slow. link status --validate and link benchmark both show persistent-cache reuse so you can tell whether Link is rereading every page or reusing unchanged records.

    -

    Use link team-sync before sharing Link through Git. It is read-only: it checks Git state, verifies that raw/ is protected, and prints paste-safe setup/sync commands without pushing private source material for you.

    +

    Use link team-sync before sharing Link through Git. It is read-only: it checks Git state, verifies that raw/ is protected, checks whether review or user-scoped memories would make sharing unsafe, and prints paste-safe setup/sync commands without pushing private source material for you.

    Use link snapshot when you need a static, read-only HTML export for a teammate, maintainer, security reviewer, or demo. It excludes raw/, captures, local operation state, and memory pages by default.

    Use link connect <agent> when an agent already has Link instructions but still needs MCP wiring. It previews the config before writing.

    link connect codex ~/link
    diff --git a/docs/team-security.html b/docs/team-security.html
    index 09ebaee..15cbe59 100644
    --- a/docs/team-security.html
    +++ b/docs/team-security.html
    @@ -89,7 +89,7 @@ 

    Team Sharing Pattern

    link team-sync ~/link --remote git@example.com:team/link-memory.git
     link compliance-export ~/link --output link-audit.json
     link backup ~/link
    -

    link team-sync is read-only. It checks Git state, raw-source protection, review readiness, and prints paste-safe commands instead of pushing data for you.

    +

    link team-sync is read-only. It checks Git state, raw-source protection, review readiness, and whether active user-scoped memories would be swept into a broad git add wiki. It prints paste-safe commands instead of pushing data for you.

    Audit Packet

    link compliance-export creates a redacted JSON packet for review. It includes readiness, validation status, memory review counts, operation markers, recent audit log metadata, and safe next actions. Raw source contents and memory bodies are excluded.

    diff --git a/mcp_package/link_core/team_sync.py b/mcp_package/link_core/team_sync.py index aea6c76..ebb788b 100644 --- a/mcp_package/link_core/team_sync.py +++ b/mcp_package/link_core/team_sync.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Mapping +from .memory import is_active_memory, memory_inbox, memory_records from .mcp_verify import display_command @@ -61,6 +62,49 @@ def _action(label: str, command: list[str]) -> dict[str, str]: } +def _memory_share_status(wiki_dir: Path) -> dict[str, object]: + if not wiki_dir.exists(): + return { + "active_count": 0, + "review_count": 0, + "user_scoped_count": 0, + "project_scoped_count": 0, + "global_scoped_count": 0, + "safe_for_team_git": False, + } + records = memory_records(wiki_dir, include_body=False) + active_records = [record for record in records if is_active_memory(record)] + review_count = int(memory_inbox(active_records, include_archived=False).get("review_count") or 0) + user_scoped = [ + record for record in active_records + if str(record.get("scope") or "user").lower() == "user" + ] + project_scoped = [ + record for record in active_records + if str(record.get("scope") or "").lower() == "project" + ] + global_scoped = [ + record for record in active_records + if str(record.get("scope") or "").lower() == "global" + ] + return { + "active_count": len(active_records), + "review_count": review_count, + "user_scoped_count": len(user_scoped), + "project_scoped_count": len(project_scoped), + "global_scoped_count": len(global_scoped), + "user_scoped": [ + { + "name": str(record.get("name") or ""), + "title": str(record.get("title") or record.get("name") or ""), + "path": str(record.get("path") or ""), + } + for record in user_scoped[:8] + ], + "safe_for_team_git": review_count == 0 and len(user_scoped) == 0, + } + + def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[str, object]: """Return a read-only plan for sharing a Link workspace through Git.""" root = _link_root(target) @@ -68,6 +112,7 @@ def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[ git_root = _find_git_root(root) remotes = _git_remote_names(git_root) gitignore = _gitignore_raw_status(root) + memory_share = _memory_share_status(wiki_dir) remote_clean = str(remote or "").strip() warnings: list[str] = [] @@ -77,6 +122,10 @@ def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[ warnings.append("raw/ is not protected by the workspace .gitignore; do not push until raw sources are intentionally handled.") if git_root and not remotes and not remote_clean: warnings.append("Git repository has no remote configured.") + if int(memory_share.get("review_count") or 0): + warnings.append("memory review inbox is not clear; review or archive pending memories before team sharing.") + if int(memory_share.get("user_scoped_count") or 0): + warnings.append("active user-scoped memories would be included by git add wiki; do not team-sync until they are archived, moved to project scope, or intentionally shared.") setup_actions: list[dict[str, str]] = [] sync_actions: list[dict[str, str]] = [ @@ -114,13 +163,20 @@ def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[ "remote": remote_clean, "remotes": remotes, "gitignore": gitignore, - "ready": bool(wiki_dir.exists() and git_root and gitignore.get("protects_raw")), + "memory_share": memory_share, + "ready": bool( + wiki_dir.exists() + and git_root + and gitignore.get("protects_raw") + and memory_share.get("safe_for_team_git") + ), "warnings": warnings, "setup_actions": setup_actions, "sync_actions": sync_actions, "notes": [ "Share wiki/ and LINK.md for team agent memory.", "Keep raw/ private unless every source is approved for the team.", + "Keep user-scoped memories private unless the user intentionally converts or archives them before Git sharing.", "Review memory inbox and validation before pushing shared memory updates.", ], } @@ -136,6 +192,14 @@ def render_team_sync_text(payload: Mapping[str, object]) -> tuple[int, str]: f"Git: {payload.get('git_root') or 'not initialized'}", f"raw/ protection: {'ok' if (payload.get('gitignore') or {}).get('protects_raw') else 'needs review'}", ] + memory_share = payload.get("memory_share") if isinstance(payload.get("memory_share"), Mapping) else {} + if memory_share: + lines.append( + "Memory share gate: " + f"{memory_share.get('active_count', 0)} active · " + f"{memory_share.get('review_count', 0)} review · " + f"{memory_share.get('user_scoped_count', 0)} user-scoped" + ) remotes = payload.get("remotes") if isinstance(remotes, list) and remotes: lines.append("Remotes: " + ", ".join(str(item) for item in remotes)) diff --git a/tests/test_team_sync_core.py b/tests/test_team_sync_core.py index db7546e..0db8f7c 100644 --- a/tests/test_team_sync_core.py +++ b/tests/test_team_sync_core.py @@ -10,6 +10,29 @@ from link_core.team_sync import build_team_sync_payload, render_team_sync_text # noqa: E402 +def write_memory(root: Path, name: str, *, scope: str = "project", review_status: str = "reviewed") -> None: + path = root / "wiki" / "memories" / f"{name}.md" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join([ + "---", + f"title: {name.replace('-', ' ').title()}", + "memory_type: preference", + f"scope: {scope}", + "project: link", + "status: active", + f"review_status: {review_status}", + "---", + "", + f"# {name.replace('-', ' ').title()}", + "", + "> **TLDR:** Team sync test memory.", + "", + ]), + encoding="utf-8", + ) + + class TeamSyncCoreTests(unittest.TestCase): def test_plan_for_workspace_without_git_includes_safe_setup(self): root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) @@ -44,7 +67,9 @@ def test_git_workspace_with_raw_protection_is_ready(self): self.assertEqual(code, 0) self.assertTrue(payload["ready"]) self.assertEqual(payload["remotes"], ["origin"]) + self.assertTrue(payload["memory_share"]["safe_for_team_git"]) self.assertIn("ready for reviewed Git sharing", text) + self.assertIn("Memory share gate: 0 active", text) self.assertIn("Safe sync loop", text) def test_git_workspace_without_raw_protection_warns(self): @@ -58,6 +83,46 @@ def test_git_workspace_without_raw_protection_warns(self): self.assertFalse(payload["ready"]) self.assertIn("raw/ is not protected", payload["warnings"][0]) + def test_user_scoped_memories_block_team_sync_readiness(self): + root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) + (root / "wiki").mkdir() + (root / "wiki" / "_link_schema.json").write_text("{}", encoding="utf-8") + (root / ".gitignore").write_text("raw/*\n", encoding="utf-8") + (root / ".git").mkdir() + (root / ".git" / "config").write_text( + '[remote "origin"]\n\turl = git@example.com:team/link-memory.git\n', + encoding="utf-8", + ) + write_memory(root, "private-preference", scope="user", review_status="reviewed") + + payload = build_team_sync_payload(root) + code, text = render_team_sync_text(payload) + + self.assertEqual(code, 0) + self.assertFalse(payload["ready"]) + self.assertEqual(payload["memory_share"]["user_scoped_count"], 1) + self.assertFalse(payload["memory_share"]["safe_for_team_git"]) + self.assertIn("active user-scoped memories", " ".join(payload["warnings"])) + self.assertIn("1 user-scoped", text) + + def test_unreviewed_memories_block_team_sync_readiness(self): + root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) + (root / "wiki").mkdir() + (root / "wiki" / "_link_schema.json").write_text("{}", encoding="utf-8") + (root / ".gitignore").write_text("raw/*\n", encoding="utf-8") + (root / ".git").mkdir() + (root / ".git" / "config").write_text( + '[remote "origin"]\n\turl = git@example.com:team/link-memory.git\n', + encoding="utf-8", + ) + write_memory(root, "pending-team-memory", scope="project", review_status="pending") + + payload = build_team_sync_payload(root) + + self.assertFalse(payload["ready"]) + self.assertEqual(payload["memory_share"]["review_count"], 1) + self.assertIn("memory review inbox is not clear", " ".join(payload["warnings"])) + if __name__ == "__main__": unittest.main() From 97a4ecaf8c3c0b44b0c9bce28e6028de2341a3d8 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 04:13:44 -0600 Subject: [PATCH 21/35] Add explicit memory visibility controls --- CHANGELOG.md | 4 ++- LINK.md | 2 ++ README.md | 18 ++++++---- docs/api.html | 2 +- docs/cli.html | 12 +++---- docs/concepts.html | 2 +- docs/mcp.html | 2 +- docs/team-security.html | 8 ++--- link.py | 9 +++++ mcp_package/README.md | 4 +-- mcp_package/link_core/capture.py | 2 ++ mcp_package/link_core/cli_memory.py | 7 +++- mcp_package/link_core/cli_parser.py | 8 ++++- mcp_package/link_core/memory.py | 43 +++++++++++++++++++++- mcp_package/link_core/snapshot.py | 55 +++++++++++++++++++++++++---- mcp_package/link_core/team_sync.py | 43 ++++++++++++++++++---- mcp_package/link_core/web_memory.py | 1 + mcp_package/link_mcp/server.py | 11 +++++- serve.py | 1 + tests/test_capture_core.py | 2 ++ tests/test_cli_parser_core.py | 10 ++++++ tests/test_memory_core.py | 41 +++++++++++++++++++++ tests/test_snapshot_core.py | 37 ++++++++++++++++++- tests/test_team_sync_core.py | 40 ++++++++++++++++++--- 24 files changed, 320 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af45ad..55e01bc 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 `link team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically. - Added `link share ` to print a local viewer permalink and agent prompt for a specific Link page. - Added `link snapshot` to export a static, read-only HTML snapshot for demos or reviews while excluding raw sources, captures, live state, and memory pages by default. +- Added memory `visibility` metadata (`private`, `project`, or `team`) so team sharing can rely on explicit user intent instead of inferring privacy from scope alone. - Added `link memory-log`, MCP `memory_log`, `/memory-log`, and `/api/memory-log` for recent memory lifecycle changes without exposing raw source or memory bodies. - Added `link wins`, MCP `memory_wins`, `/wins`, and `/api/wins` for local, non-telemetry proof signals about what Link memory is carrying. - Added a team security review docs page covering local deployment, data boundaries, memory approval gates, Git sharing, audit exports, and current limits. @@ -26,7 +27,8 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ### Changed - Broadened local secret detection for common modern provider tokens and credentials before capture, ingest, Obsidian import, and doctor scans. -- Tightened `link team-sync` readiness so unreviewed memories or active user-scoped memories block "ready" status before Git sharing. +- Tightened `link team-sync` readiness so unreviewed memories or active `visibility: private` memories block "ready" status before Git sharing. +- Tightened `link snapshot --include-memories` so private memories stay excluded unless `--include-private-memories` is explicitly passed. - Broadened Windows CI from a small portability subset to most non-installer/non-server tests. - Clarified that the Homebrew formula lives in the separate `gowtham0992/homebrew-link` tap. - Tightened security reporting guidance to prefer private maintainer contact before public GitHub issues. diff --git a/LINK.md b/LINK.md index 69d7f87..8cbeed5 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 +visibility: private | project | team project: "optional-project-slug" status: active | stale | archived date_captured: "2026-04-09T14:30:00Z" @@ -288,6 +289,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. +- Use `visibility: private` for personal memory, `project` for project-team sharing, and `team` only when the human explicitly wants the memory shared across a team workspace. If omitted, Link treats user/global memories as private and project memories as project-visible. - 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, 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. diff --git a/README.md b/README.md index 0292359..aef71b0 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,8 @@ creating a duplicate. - `ingest_status`: exact next steps for raw files, including source safety, stale ingest detection, validation, and memory proposal guidance. - `remember_memory`: durable local memory with duplicate/conflict checks, - review state, optional `review_after` re-check dates, optional `expires_at` - expiry dates, provenance, and audit logging. + `visibility` sharing intent, review state, optional `review_after` re-check + dates, optional `expires_at` expiry dates, provenance, and audit logging. - `explain_memory`: why a memory exists, what it links to, whether it is ready for recall, and what needs review. - `memory_log`: recent memory lifecycle changes from `wiki/log.md`, without @@ -331,6 +331,9 @@ user to confirm, update, archive, or forget it instead of trusting stale context Use `expires_at` for temporary context that should automatically leave default recall after a date; Link keeps the Markdown page inspectable and asks the user to update, archive, or delete it. +Use `visibility` to separate where a memory applies from who should see it: +`private` stays personal, `project` is intended for a project workspace, and +`team` means the user explicitly approved sharing it with a team. For team handoff or security review, `link compliance-export --output audit.json` writes a redacted JSON packet with readiness, validation, memory review status, @@ -348,8 +351,8 @@ prompts without tracking user behavior. For Git-backed team memory, `link team-sync ~/link` checks whether the workspace is ready to share reviewed `wiki/` pages while keeping `raw/`, caches, backups, and local MCP Python markers private by default. It also blocks "ready" status -when the memory inbox is not clear or active user-scoped memories would be -included by a broad `git add wiki`. +when the memory inbox is not clear or active `visibility: private` memories +would be included by a broad `git add wiki`. ```bash link team-sync ~/link --remote git@example.com:team/link-memory.git @@ -364,12 +367,15 @@ link share "Prefer local memory" ~/link For a static, read-only review packet, `link snapshot` exports rendered wiki HTML without `raw/`, captures, operation markers, live MCP state, or memory pages -by default. It blocks export if wiki pages contain secret-looking values unless -you explicitly override it. +by default. `--include-memories` exports only non-private memories; use +`--include-private-memories` only for a personal archive or an explicitly +approved review. It blocks export if wiki pages contain secret-looking values +unless you explicitly override it. ```bash link snapshot ~/link --output link-snapshot link snapshot ~/link --output link-snapshot --include-memories --force +link snapshot ~/link --output personal-snapshot --include-memories --include-private-memories --force ``` ## Agent Contract diff --git a/docs/api.html b/docs/api.html index 4bed585..376a2bc 100644 --- a/docs/api.html +++ b/docs/api.html @@ -92,7 +92,7 @@

    Write Endpoints

    POST /api/rebuild-backlinks POST /api/rebuild-index

    Web memory approval APIs intentionally do not honor duplicate/conflict override flags. If Link reports a duplicate or conflict, review the existing memory and use the CLI or MCP tool explicitly after deciding what should coexist.

    -

    POST /api/remember-memory accepts optional review_after and expires_at dates in YYYY-MM-DD form for scheduled review and temporary-memory expiry.

    +

    POST /api/remember-memory accepts optional visibility, review_after, and expires_at fields for sharing intent, scheduled review, and temporary-memory expiry.

    Large Wiki Endpoints

    Agents and integrations should prefer bounded endpoints over full dumps:

    diff --git a/docs/cli.html b/docs/cli.html index 66ffef5..3b49b2a 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -66,7 +66,7 @@

    Daily Loop

    link next link health link ingest-status -link remember "User prefers feature branches for Link work." --type preference --scope project --project link --review-after 2026-08-01 +link remember "User prefers feature branches for Link work." --type preference --scope project --project link --visibility project --review-after 2026-08-01 link remember "Temporary launch branch is release/one-off." --type project --project link --expires-at 2026-09-01 link share "Prefer local memory" link snapshot ~/link --output link-snapshot @@ -108,8 +108,8 @@

    Maintenance

    link validate link verify-mcp

    Use link backup before broad repair work. Use link benchmark when a wiki starts to feel slow. link status --validate and link benchmark both show persistent-cache reuse so you can tell whether Link is rereading every page or reusing unchanged records.

    -

    Use link team-sync before sharing Link through Git. It is read-only: it checks Git state, verifies that raw/ is protected, checks whether review or user-scoped memories would make sharing unsafe, and prints paste-safe setup/sync commands without pushing private source material for you.

    -

    Use link snapshot when you need a static, read-only HTML export for a teammate, maintainer, security reviewer, or demo. It excludes raw/, captures, local operation state, and memory pages by default.

    +

    Use link team-sync before sharing Link through Git. It is read-only: it checks Git state, verifies that raw/ is protected, checks whether review or visibility: private memories would make sharing unsafe, and prints paste-safe setup/sync commands without pushing private source material for you.

    +

    Use link snapshot when you need a static, read-only HTML export for a teammate, maintainer, security reviewer, or demo. It excludes raw/, captures, local operation state, and memory pages by default. With --include-memories, it still excludes visibility: private memories unless --include-private-memories is explicitly passed.

    Use link connect <agent> when an agent already has Link instructions but still needs MCP wiring. It previews the config before writing.

    link connect codex ~/link
     link connect codex ~/link --write
    @@ -134,14 +134,14 @@ 

    All Commands

    link compliance-export [dir] [--output audit.json] [--project slug] link team-sync [dir] [--remote git-url] link share <page-or-memory> [dir] [--port 3000] -link snapshot [dir] [--output link-snapshot] [--include-memories] [--force] +link snapshot [dir] [--output link-snapshot] [--include-memories] [--include-private-memories] [--force] link ingest-status link import-obsidian <vault> [dir] [--dry-run] [--overwrite] -link remember "text" [--project slug] [--review-after YYYY-MM-DD] [--expires-at YYYY-MM-DD] +link remember "text" [--project slug] [--visibility private|project|team] [--review-after YYYY-MM-DD] [--expires-at YYYY-MM-DD] link propose-memories <file-or-text> [--project slug] link capture-session <file-or-text> [--project slug] link capture-inbox [--project slug] -link accept-capture <capture> [--index N] +link accept-capture <capture> [--index N] [--visibility private|project|team] link redact-capture <capture> link delete-capture <capture> --confirm link query "task" [--budget small|medium|large] [--project slug] diff --git a/docs/concepts.html b/docs/concepts.html index 37be744..80eeaf7 100644 --- a/docs/concepts.html +++ b/docs/concepts.html @@ -84,7 +84,7 @@

    Three User Moves

    Raw files do not silently personalize future agents. Ingest creates source-backed wiki knowledge. Explicit remember creates durable user or project memory.

    Memory Lifecycle

    -

    A memory is a Markdown page with status, scope, source, review state, optional review_after and expires_at dates, graph links, and local log entries. It can be proposed, remembered, reviewed, updated, archived, restored, explained, or forgotten.

    +

    A memory is a Markdown page with status, scope, visibility, source, review state, optional review_after and expires_at dates, graph links, and local log entries. It can be proposed, remembered, reviewed, updated, archived, restored, explained, or forgotten.

    Propose

    Generate candidate memories from chat notes or raw captures without writing durable memory.

    Approve

    Save only the memories the user explicitly wants agents to carry forward.

    diff --git a/docs/mcp.html b/docs/mcp.html index e46a75c..826987d 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -166,7 +166,7 @@

    MCP Tools

    get_graph rebuild_index rebuild_backlinks
    -

    Memory write tools return duplicate_candidates or conflict_candidates when review, update, or archive is safer than creating another memory page. remember_memory also accepts optional review_after and expires_at dates for scheduled re-checks and temporary memories.

    +

    Memory write tools return duplicate_candidates or conflict_candidates when review, update, or archive is safer than creating another memory page. remember_memory also accepts optional visibility, review_after, and expires_at fields for sharing intent, scheduled re-checks, and temporary memories.

    Project-aware tools accept an optional project argument. When set, Link returns broad user/global memory plus memories for that project, while excluding memories from other explicit projects.

    Verify Setup

    diff --git a/docs/team-security.html b/docs/team-security.html index 15cbe59..9d0e743 100644 --- a/docs/team-security.html +++ b/docs/team-security.html @@ -77,19 +77,19 @@

    Data Boundaries

    link validate ~/link

    Memory Approval Gates

    -

    Agents can propose memories, but durable memory should be explicit and reviewable. Link keeps memory as Markdown with type, scope, project, source, review status, optional review dates, and optional expiry dates.

    +

    Agents can propose memories, but durable memory should be explicit and reviewable. Link keeps memory as Markdown with type, scope, visibility, project, source, review status, optional review dates, and optional expiry dates.

    link propose-memories raw/notes.md ~/link
     link memory-inbox ~/link
     link review-memory memory-name ~/link
     link archive-memory memory-name ~/link --reason stale
    -

    For temporary context, use expires_at. For decisions that should be re-confirmed, use review_after.

    +

    For temporary context, use expires_at. For decisions that should be re-confirmed, use review_after. For team handoff, keep personal context at visibility: private and only mark memories project or team after the user explicitly approves sharing them.

    Team Sharing Pattern

    The safest early team workflow is Git-backed sharing of reviewed wiki pages. Keep raw sources local unless the team explicitly decides to share them.

    link team-sync ~/link --remote git@example.com:team/link-memory.git
     link compliance-export ~/link --output link-audit.json
     link backup ~/link
    -

    link team-sync is read-only. It checks Git state, raw-source protection, review readiness, and whether active user-scoped memories would be swept into a broad git add wiki. It prints paste-safe commands instead of pushing data for you.

    +

    link team-sync is read-only. It checks Git state, raw-source protection, review readiness, and whether active visibility: private memories would be swept into a broad git add wiki. It prints paste-safe commands instead of pushing data for you.

    Audit Packet

    link compliance-export creates a redacted JSON packet for review. It includes readiness, validation status, memory review counts, operation markers, recent audit log metadata, and safe next actions. Raw source contents and memory bodies are excluded.

    @@ -112,7 +112,7 @@

    Security Review Checklist

  • Run link compliance-export ~/link --output link-audit.json.
  • Confirm raw/, backups, caches, and local MCP Python markers are ignored by Git.
  • Review wiki/log.md, link memory-log ~/link, and link wins ~/link.
  • -
  • Only share reviewed wiki/ pages that your team intends to share.
  • +
  • Only share reviewed wiki/ pages whose memories are marked visibility: project or visibility: team.
  • diff --git a/link.py b/link.py index daa7a15..3c7827f 100644 --- a/link.py +++ b/link.py @@ -579,6 +579,7 @@ def _write_memory_page( tags: str | None = None, source: str = "manual", timestamp: str | None = None, allow_duplicate: bool = False, allow_conflict: bool = False, project: str | None = None, + visibility: str | None = None, review_after: str | None = None, expires_at: str | None = None, ) -> dict[str, object]: @@ -588,6 +589,7 @@ def _write_memory_page( return _core_write_memory_page( wiki_dir, clean_text, title=title, memory_type=memory_type, scope=scope, tags=tags, source=source, + visibility=visibility, review_after=review_after, expires_at=expires_at, allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, @@ -833,6 +835,7 @@ def snapshot( target: Path, output: str = "link-snapshot", include_memories: bool = False, + include_private_memories: bool = False, allow_sensitive: bool = False, force: bool = False, title: str = "Link", @@ -843,6 +846,7 @@ def snapshot( wiki_dir, Path(output), include_memories=include_memories, + include_private_memories=include_private_memories, allow_sensitive=allow_sensitive, force=force, title=title, @@ -941,6 +945,7 @@ def remember( allow_duplicate: bool = False, allow_conflict: bool = False, project: str | None = None, + visibility: str | None = None, review_after: str | None = None, expires_at: str | None = None, json_output: bool = False, @@ -960,6 +965,7 @@ def remember( allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, project=project or _default_project(target), + visibility=visibility, review_after=review_after, expires_at=expires_at, ) @@ -1152,6 +1158,7 @@ def accept_capture( scope: str | None = None, tags: str | None = None, project: str | None = None, + visibility: str | None = None, allow_duplicate: bool = False, allow_conflict: bool = False, json_output: bool = False, @@ -1197,6 +1204,7 @@ def accept_capture( title=title, memory_type=memory_type, scope=scope, + visibility=visibility, tags=tags, ) result = _write_memory_page( @@ -1205,6 +1213,7 @@ def accept_capture( title=str(memory_args["title"]), memory_type=str(memory_args["memory_type"]), scope=str(memory_args["scope"]), + visibility=str(memory_args["visibility"] or "") or None, tags=memory_args["tags"] if isinstance(memory_args["tags"], str) else None, source=str(memory_args["source"]), allow_duplicate=allow_duplicate, diff --git a/mcp_package/README.md b/mcp_package/README.md index 4ab0074..1c55468 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -147,11 +147,11 @@ In the local web proposal picker, unreadable raw files are surfaced as | `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?, 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?, review_after?, expires_at?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. `review_after` accepts `YYYY-MM-DD` for scheduled re-checks; `expires_at` accepts `YYYY-MM-DD` for temporary memories that should leave default recall. | +| `remember_memory(memory, title?, memory_type?, scope?, tags?, source?, allow_duplicate?, allow_conflict?, project?, visibility?, review_after?, expires_at?)` | Save an explicit user-approved local memory under `wiki/memories/`; strong duplicates and likely conflicts require explicit override. `visibility` accepts `private`, `project`, or `team` sharing intent. `review_after` accepts `YYYY-MM-DD` for scheduled re-checks; `expires_at` accepts `YYYY-MM-DD` for temporary memories that should leave default recall. | | `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. | | `capture_inbox(limit?, project?)` | Review saved raw captures with redacted snippets, secret-warning labels, and accept/redact/delete commands. | -| `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. | +| `accept_capture(capture, index?, title?, memory_type?, scope?, visibility?, 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. | diff --git a/mcp_package/link_core/capture.py b/mcp_package/link_core/capture.py index 2e9e321..91bc53d 100644 --- a/mcp_package/link_core/capture.py +++ b/mcp_package/link_core/capture.py @@ -234,6 +234,7 @@ def capture_accept_memory_args( title: str | None = None, memory_type: str | None = None, scope: str | None = None, + visibility: str | None = None, tags: str | None = None, ) -> dict[str, object]: """Build write_memory_page arguments for an accepted capture proposal.""" @@ -247,6 +248,7 @@ def capture_accept_memory_args( "title": title or str(proposal.get("title") or "Memory"), "memory_type": memory_type or str(proposal.get("memory_type") or "note"), "scope": chosen_scope, + "visibility": visibility or str(proposal.get("visibility") or ""), "tags": tags, "source": str(selection.get("capture") or ""), "project": project_name if chosen_scope == "project" else "", diff --git a/mcp_package/link_core/cli_memory.py b/mcp_package/link_core/cli_memory.py index 5862ca4..0ae7560 100644 --- a/mcp_package/link_core/cli_memory.py +++ b/mcp_package/link_core/cli_memory.py @@ -58,6 +58,7 @@ def render_remember_text(result: Mapping[str, object], *, target: object = ".") f"Title requested: {result['title']}", f"Type: {result['memory_type']}", f"Scope: {result['scope']}", + f"Visibility: {result.get('visibility', 'private')}", "", "Conflict candidates:", *_candidate_lines(result.get("conflict_candidates", []), include_reasons=True), @@ -75,6 +76,7 @@ def render_remember_text(result: Mapping[str, object], *, target: object = ".") f"Title requested: {result['title']}", f"Type: {result['memory_type']}", f"Scope: {result['scope']}", + f"Visibility: {result.get('visibility', 'private')}", "", "Existing candidates:", *_candidate_lines(result.get("candidates", [])), @@ -93,6 +95,7 @@ def render_remember_text(result: Mapping[str, object], *, target: object = ".") f"Path: {result['path']}", f"Type: {result['memory_type']}", f"Scope: {result['scope']}", + f"Visibility: {result.get('visibility', 'private')}", ] if result.get("project"): lines.append(f"Project: {result['project']}") @@ -430,7 +433,7 @@ def render_explain_memory_text(explanation: Mapping[str, object]) -> tuple[int, f"Link memory explanation: {memory['title']}", "", f"Path: {memory['path']}", - f"Type: {memory['memory_type']} · Scope: {memory['scope']} · Status: {lifecycle['status']}", + f"Type: {memory['memory_type']} · Scope: {memory['scope']} · Visibility: {memory.get('visibility', 'private')} · Status: {lifecycle['status']}", f"Source: {provenance['source'] or 'missing'}", f"Captured: {provenance['date_captured'] or 'missing'}", f"Review: {review['status']} · Issues: {review['issue_count']}", @@ -499,6 +502,7 @@ def render_brief_text(payload: Mapping[str, object], *, query: str = "", project ), f"Types: {format_counts(profile_data['by_type'])}", f"Scopes: {format_counts(profile_data['by_scope'])}", + f"Visibility: {format_counts(profile_data.get('by_visibility', {}))}", "", render_memory_list("Relevant memories", payload.get("relevant_memories", [])), ]) @@ -552,6 +556,7 @@ def render_profile_text( f"{memory_count} memor{'y' if memory_count == 1 else 'ies'} · {active_count} active · {review_count} need review", f"Types: {format_counts(profile_data['by_type'])}", f"Scopes: {format_counts(profile_data['by_scope'])}", + f"Visibility: {format_counts(profile_data.get('by_visibility', {}))}", ]) if profile_data["by_project"]: lines.append(f"Projects: {format_counts(profile_data['by_project'])}") diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index fdb7303..aaa2210 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from .memory import MEMORY_SCOPES, MEMORY_TYPES +from .memory import MEMORY_SCOPES, MEMORY_TYPES, MEMORY_VISIBILITIES from .version import LINK_VERSION @@ -94,6 +94,7 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum snapshot_cmd.add_argument("target", nargs="?", default=".") snapshot_cmd.add_argument("--output", default="link-snapshot", help="directory to write the snapshot into") snapshot_cmd.add_argument("--include-memories", action="store_true", help="include memory pages intentionally") + snapshot_cmd.add_argument("--include-private-memories", action="store_true", help="include visibility: private memory pages too") snapshot_cmd.add_argument("--allow-sensitive", action="store_true", help="export even if wiki pages contain secret-looking values") snapshot_cmd.add_argument("--force", action="store_true", help="replace a non-empty output directory") snapshot_cmd.add_argument("--title", default="Link", help="snapshot title") @@ -130,6 +131,7 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum 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("--visibility", choices=MEMORY_VISIBILITIES, default=None, help="sharing intent: private, project, or team") 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") @@ -167,6 +169,7 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum 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("--visibility", choices=MEMORY_VISIBILITIES, default=None, help="sharing intent for the accepted memory") 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") @@ -372,6 +375,7 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: Path(args.target), output=args.output, include_memories=args.include_memories, + include_private_memories=args.include_private_memories, allow_sensitive=args.allow_sensitive, force=args.force, title=args.title, @@ -401,6 +405,7 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: title=args.title, memory_type=args.memory_type, scope=args.scope, + visibility=args.visibility, tags=args.tags, source=args.source, project=args.project, @@ -442,6 +447,7 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: title=args.title, memory_type=args.memory_type, scope=args.scope, + visibility=args.visibility, tags=args.tags, project=args.project, allow_duplicate=args.allow_duplicate, diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index eea4dab..6b6cbfd 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -30,6 +30,7 @@ MEMORY_TYPES = ("preference", "decision", "project", "fact", "note") MEMORY_SCOPES = ("user", "project", "global") +MEMORY_VISIBILITIES = ("private", "project", "team") MEMORY_REVIEW_STATUSES = ("pending", "reviewed", "needs_update") MEMORY_PROPOSAL_MIN_SCORE = 70 MEMORY_CONFLICT_TYPES = {"preference", "decision", "project"} @@ -114,6 +115,20 @@ def normalize_project(value: str | None) -> str: return slugify(value or "", fallback="") +def default_memory_visibility(scope: str) -> str: + """Return the safest sharing visibility for a memory scope.""" + return "project" if scope == "project" else "private" + + +def normalize_memory_visibility(scope: str, visibility: object | None = None) -> str: + value = str(visibility or "").strip().lower() + if not value: + return default_memory_visibility(scope) + if value not in MEMORY_VISIBILITIES: + raise ValueError(f"visibility must be one of: {', '.join(MEMORY_VISIBILITIES)}") + return value + + def default_project_for_target(target: Path) -> str: resolved = target.expanduser().resolve() if resolved.name == "wiki" and (resolved / "index.md").exists(): @@ -262,12 +277,18 @@ def memory_record_from_page(wiki_dir: Path, path: Path, include_body: bool = Tru text = path.read_text(encoding="utf-8", errors="replace") meta, body = parse_frontmatter(text) title = meta.get("title") or _heading_title(body) or memory_title(body) or path.stem + scope = str(meta.get("scope") or "user").lower() + try: + visibility = normalize_memory_visibility(scope, meta.get("visibility")) + except ValueError: + visibility = str(meta.get("visibility") or "") record: dict[str, object] = { "name": path.stem, "path": f"wiki/{path.relative_to(wiki_root).as_posix()}", "title": title, "memory_type": meta.get("memory_type") or "note", - "scope": meta.get("scope") or "user", + "scope": scope, + "visibility": visibility, "project": normalize_project(str(meta.get("project", ""))), "status": meta.get("status") or "active", "date_captured": meta.get("date_captured", ""), @@ -314,6 +335,7 @@ def memory_review_issues( review_status = str(record.get("review_status") or "pending").lower() memory_type = str(record.get("memory_type") or "") scope = str(record.get("scope") or "") + visibility = str(record.get("visibility") or default_memory_visibility(scope)) if review_status in {"pending", "needs_review"}: issues.append({ @@ -397,6 +419,13 @@ def memory_review_issues( "message": f"Unknown scope: {scope or 'missing'}.", "suggested_action": f"Use one of: {', '.join(MEMORY_SCOPES)}.", }) + if visibility not in MEMORY_VISIBILITIES: + issues.append({ + "code": "invalid_visibility", + "severity": "high", + "message": f"Unknown visibility: {visibility or 'missing'}.", + "suggested_action": f"Use one of: {', '.join(MEMORY_VISIBILITIES)}.", + }) if not str(record.get("source") or "").strip(): issues.append({ "code": "missing_source", @@ -1171,6 +1200,7 @@ def write_memory_page( source: str, timestamp: str, project: str | None = None, + visibility: str | None = None, review_after: str | None = None, expires_at: str | None = None, records: Iterable[Mapping[str, object]] | None = None, @@ -1183,6 +1213,7 @@ def write_memory_page( 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)}") + clean_visibility = normalize_memory_visibility(scope, visibility) clean_text = text.strip() if not clean_text: @@ -1216,6 +1247,7 @@ def write_memory_page( "title": memory_title_value, "memory_type": memory_type, "scope": scope, + "visibility": clean_visibility, "project": clean_project, "candidates": duplicate_candidates, } @@ -1235,6 +1267,7 @@ def write_memory_page( "title": memory_title_value, "memory_type": memory_type, "scope": scope, + "visibility": clean_visibility, "project": clean_project, "conflict_candidates": conflict_candidates, } @@ -1257,6 +1290,7 @@ def write_memory_page( title: "{frontmatter_string(memory_title_value)}" memory_type: {memory_type} scope: {scope} +visibility: {clean_visibility} {project_line}status: active date_captured: "{timestamp}" source: "{frontmatter_string(clean_source)}" @@ -1300,6 +1334,7 @@ def write_memory_page( f"Created: memories/{page_path.name}", f"Type: {memory_type}", f"Scope: {scope}", + f"Visibility: {clean_visibility}", ], ) backlinks_rebuilt = rebuild_backlinks() if rebuild_backlinks else False @@ -1310,6 +1345,7 @@ def write_memory_page( "title": memory_title_value, "memory_type": memory_type, "scope": scope, + "visibility": clean_visibility, "project": clean_project, "review_after": clean_review_after, "expires_at": clean_expires_at, @@ -1448,6 +1484,7 @@ def typed(memory_type: str) -> list[dict[str, object]]: "project": project_name, "by_type": count_values(record_list, "memory_type"), "by_scope": count_values(record_list, "scope"), + "by_visibility": count_values(record_list, "visibility"), "by_project": count_values( [ record @@ -2040,6 +2077,7 @@ def memory_proposal_action(proposal: Mapping[str, object], *, command_target: st title = str(proposal.get("title") or proposal_title(memory, str(proposal.get("memory_type") or "note"))) memory_type = str(proposal.get("memory_type") or "note") scope = str(proposal.get("scope") or "user") + visibility = str(proposal.get("visibility") or default_memory_visibility(scope)) source = str(proposal.get("source") or "proposal") project = str(proposal.get("project") or "") duplicate_candidates = proposal.get("duplicate_candidates") @@ -2105,6 +2143,8 @@ def memory_proposal_action(proposal: Mapping[str, object], *, command_target: st memory_type, "--scope", scope, + "--visibility", + visibility, "--source", source, ] @@ -2113,6 +2153,7 @@ def memory_proposal_action(proposal: Mapping[str, object], *, command_target: st "title": title, "memory_type": memory_type, "scope": scope, + "visibility": visibility, "source": source, } if project: diff --git a/mcp_package/link_core/snapshot.py b/mcp_package/link_core/snapshot.py index 09aa400..480765e 100644 --- a/mcp_package/link_core/snapshot.py +++ b/mcp_package/link_core/snapshot.py @@ -11,6 +11,7 @@ from .files import atomic_write_json, atomic_write_text from .markdown import markdown_to_html +from .memory import default_memory_visibility from .search import close_wiki_cache from .security import find_sensitive_values from .wiki import build_wiki_cache @@ -104,16 +105,33 @@ def _category_title(category: str) -> str: }.get(category, category.replace("-", " ").title()) -def _include_page(page: Mapping[str, Any], *, include_memories: bool) -> bool: +def _include_page( + page: Mapping[str, Any], + *, + include_memories: bool, + include_private_memories: bool, + meta_index: Mapping[str, Mapping[str, Any]], +) -> bool: name = str(page.get("name") or "").lower() category = str(page.get("category") or "") if name in {"index", "log"}: return False - if category == "memories" and not include_memories: - return False + if category == "memories": + if not include_memories: + return False + if _memory_visibility(name, meta_index) == "private" and not include_private_memories: + return False return True +def _memory_visibility(name: str, meta_index: Mapping[str, Mapping[str, Any]]) -> str: + meta = meta_index.get(name.lower(), {}) + if not isinstance(meta, Mapping): + meta = {} + scope = str(meta.get("scope") or "user").lower() + return str(meta.get("visibility") or default_memory_visibility(scope)).lower() + + def _metadata_line(page: Mapping[str, Any]) -> str: parts = [ str(page.get("category") or ""), @@ -174,7 +192,12 @@ def _index_html( f"
      {''.join(items)}
    " "" ) - memory_note = "included" if include_memories else "excluded by default" + if not include_memories: + memory_note = "excluded by default" + elif excluded_counts.get("private_memories"): + memory_note = "non-private included" + else: + memory_note = "included" body = ( "
    " f"

    {html.escape(title)}

    Link read-only snapshot
    " @@ -187,6 +210,7 @@ def _index_html( "Secret-looking wiki contents are blocked before export unless explicitly overridden." "" f"

    Excluded: {int(excluded_counts.get('memories', 0))} memories, " + f"{int(excluded_counts.get('private_memories', 0))} private memories, " f"{int(excluded_counts.get('root', 0))} generated root pages.

    " f"
    {''.join(sections)}
    " "" @@ -222,6 +246,7 @@ def export_snapshot( output_dir: Path, *, include_memories: bool = False, + include_private_memories: bool = False, allow_sensitive: bool = False, force: bool = False, title: str = "Link", @@ -268,12 +293,28 @@ def export_snapshot( resolved_cache = cache or build_wiki_cache(wiki_dir) try: all_pages = [page for page in list(resolved_cache.get("pages") or []) if isinstance(page, Mapping)] + meta_index = resolved_cache.get("meta_index") if isinstance(resolved_cache.get("meta_index"), dict) else {} included_pages = [ page for page in all_pages - if _include_page(page, include_memories=include_memories) + if _include_page( + page, + include_memories=include_memories, + include_private_memories=include_private_memories, + meta_index=meta_index, + ) ] excluded_counts = { - "memories": sum(1 for page in all_pages if str(page.get("category") or "") == "memories" and page not in included_pages), + "memories": sum( + 1 + for page in all_pages + if str(page.get("category") or "") == "memories" and page not in included_pages + ), + "private_memories": sum( + 1 + for page in all_pages + if str(page.get("category") or "") == "memories" + and _memory_visibility(str(page.get("name") or ""), meta_index) == "private" + ), "root": sum(1 for page in all_pages if str(page.get("name") or "").lower() in {"index", "log"}), } page_href = { @@ -311,6 +352,7 @@ def export_snapshot( "created_at": created_at, "page_count": len(included_pages), "include_memories": include_memories, + "include_private_memories": include_private_memories, "excluded_counts": excluded_counts, "privacy_note": ( "Snapshot includes rendered wiki pages only. Raw sources, raw captures, operation markers, " @@ -357,6 +399,7 @@ def render_snapshot_text(payload: Mapping[str, object]) -> tuple[int, str]: f"Open: {payload.get('index')}", f"Pages: {payload.get('page_count')}", f"Memories included: {'yes' if payload.get('include_memories') else 'no'}", + f"Private memories included: {'yes' if payload.get('include_private_memories') else 'no'}", "", "Safe sharing note:", f" {payload.get('privacy_note')}", diff --git a/mcp_package/link_core/team_sync.py b/mcp_package/link_core/team_sync.py index ebb788b..7f2cab5 100644 --- a/mcp_package/link_core/team_sync.py +++ b/mcp_package/link_core/team_sync.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Mapping -from .memory import is_active_memory, memory_inbox, memory_records +from .memory import default_memory_visibility, is_active_memory, memory_inbox, memory_records from .mcp_verify import display_command @@ -55,6 +55,11 @@ def _gitignore_raw_status(root: Path) -> dict[str, object]: return {"path": str(path), "exists": True, "protects_raw": protects_raw} +def _record_visibility(record: Mapping[str, object]) -> str: + scope = str(record.get("scope") or "user").lower() + return str(record.get("visibility") or default_memory_visibility(scope)).lower() + + def _action(label: str, command: list[str]) -> dict[str, str]: return { "label": label, @@ -68,6 +73,9 @@ def _memory_share_status(wiki_dir: Path) -> dict[str, object]: "active_count": 0, "review_count": 0, "user_scoped_count": 0, + "private_visibility_count": 0, + "project_visibility_count": 0, + "team_visibility_count": 0, "project_scoped_count": 0, "global_scoped_count": 0, "safe_for_team_git": False, @@ -79,6 +87,18 @@ def _memory_share_status(wiki_dir: Path) -> dict[str, object]: record for record in active_records if str(record.get("scope") or "user").lower() == "user" ] + private_visibility = [ + record for record in active_records + if _record_visibility(record) == "private" + ] + project_visibility = [ + record for record in active_records + if _record_visibility(record) == "project" + ] + team_visibility = [ + record for record in active_records + if _record_visibility(record) == "team" + ] project_scoped = [ record for record in active_records if str(record.get("scope") or "").lower() == "project" @@ -91,6 +111,9 @@ def _memory_share_status(wiki_dir: Path) -> dict[str, object]: "active_count": len(active_records), "review_count": review_count, "user_scoped_count": len(user_scoped), + "private_visibility_count": len(private_visibility), + "project_visibility_count": len(project_visibility), + "team_visibility_count": len(team_visibility), "project_scoped_count": len(project_scoped), "global_scoped_count": len(global_scoped), "user_scoped": [ @@ -101,7 +124,15 @@ def _memory_share_status(wiki_dir: Path) -> dict[str, object]: } for record in user_scoped[:8] ], - "safe_for_team_git": review_count == 0 and len(user_scoped) == 0, + "private_visibility": [ + { + "name": str(record.get("name") or ""), + "title": str(record.get("title") or record.get("name") or ""), + "path": str(record.get("path") or ""), + } + for record in private_visibility[:8] + ], + "safe_for_team_git": review_count == 0 and len(private_visibility) == 0, } @@ -124,8 +155,8 @@ def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[ warnings.append("Git repository has no remote configured.") if int(memory_share.get("review_count") or 0): warnings.append("memory review inbox is not clear; review or archive pending memories before team sharing.") - if int(memory_share.get("user_scoped_count") or 0): - warnings.append("active user-scoped memories would be included by git add wiki; do not team-sync until they are archived, moved to project scope, or intentionally shared.") + if int(memory_share.get("private_visibility_count") or 0): + warnings.append("active private memories would be included by git add wiki; do not team-sync until they are archived or marked visibility: project/team intentionally.") setup_actions: list[dict[str, str]] = [] sync_actions: list[dict[str, str]] = [ @@ -176,7 +207,7 @@ def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[ "notes": [ "Share wiki/ and LINK.md for team agent memory.", "Keep raw/ private unless every source is approved for the team.", - "Keep user-scoped memories private unless the user intentionally converts or archives them before Git sharing.", + "Keep visibility: private memories out of team Git until the user intentionally converts or archives them.", "Review memory inbox and validation before pushing shared memory updates.", ], } @@ -198,7 +229,7 @@ def render_team_sync_text(payload: Mapping[str, object]) -> tuple[int, str]: "Memory share gate: " f"{memory_share.get('active_count', 0)} active · " f"{memory_share.get('review_count', 0)} review · " - f"{memory_share.get('user_scoped_count', 0)} user-scoped" + f"{memory_share.get('private_visibility_count', 0)} private" ) remotes = payload.get("remotes") if isinstance(remotes, list) and remotes: diff --git a/mcp_package/link_core/web_memory.py b/mcp_package/link_core/web_memory.py index fc82aab..96a8cfe 100644 --- a/mcp_package/link_core/web_memory.py +++ b/mcp_package/link_core/web_memory.py @@ -140,6 +140,7 @@ def render_memory_card( meta_parts = [ str(record.get("memory_type") or "note"), str(record.get("scope") or "user"), + f'visibility {record.get("visibility") or "private"}', str(record.get("status") or "active"), ] if record.get("updated_at"): diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 79a9640..4a84c90 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -572,6 +572,7 @@ def _accept_capture( title: str = "", memory_type: str = "", scope: str = "", + visibility: str = "", tags: str = "", project: str = "", allow_duplicate: bool = False, @@ -599,6 +600,7 @@ def _accept_capture( title=_clean_text_input(title), memory_type=_clean_text_input(memory_type).lower(), scope=_clean_text_input(scope).lower(), + visibility=_clean_text_input(visibility).lower(), tags=tags, ) result = _write_mcp_memory_page( @@ -606,6 +608,7 @@ def _accept_capture( title=str(memory_args["title"]), memory_type=str(memory_args["memory_type"]), scope=str(memory_args["scope"]), + visibility=str(memory_args["visibility"] or ""), tags=memory_args["tags"] if isinstance(memory_args["tags"], str) else "", source=str(memory_args["source"]), allow_duplicate=allow_duplicate, @@ -767,7 +770,7 @@ def _write_mcp_memory_page( text: str, title: str = "", memory_type: str = "note", scope: str = "user", tags: str = "", source: str = "mcp", allow_duplicate: bool = False, allow_conflict: bool = False, project: str = "", - review_after: str = "", expires_at: str = "", + visibility: str = "", review_after: str = "", expires_at: str = "", ) -> dict[str, object]: clean_text = _required_text_input(text, "memory text required", max_len=4000) memory_type, scope = _memory_type_scope(memory_type, scope) @@ -777,6 +780,7 @@ def _write_mcp_memory_page( WIKI_DIR, clean_text, title=_clean_text_input(title), memory_type=memory_type, scope=scope, tags=_clean_text_input(tags, max_len=500), source=_clean_text_input(source, max_len=500), + visibility=_clean_text_input(visibility, max_len=40) or None, review_after=_clean_text_input(review_after, max_len=40) or None, expires_at=_clean_text_input(expires_at, max_len=40) or None, allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, @@ -1009,6 +1013,7 @@ def accept_capture( title: str = "", memory_type: str = "", scope: str = "", + visibility: str = "", tags: str = "", project: str = "", allow_duplicate: bool = False, @@ -1026,6 +1031,7 @@ def accept_capture( title=title, memory_type=memory_type, scope=scope, + visibility=visibility, tags=tags, project=project, allow_duplicate=allow_duplicate, @@ -1220,6 +1226,7 @@ def remember_memory( allow_duplicate: bool = False, allow_conflict: bool = False, project: str = "", + visibility: str = "", review_after: str = "", expires_at: str = "", ) -> str: @@ -1231,6 +1238,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. + visibility: private, project, or team. Defaults to private for user/global and project for project-scoped memories. project: optional project key for project-scoped memories. tags: optional comma-separated tags. review_after: optional YYYY-MM-DD date when this memory should be checked again. @@ -1247,6 +1255,7 @@ def remember_memory( allow_duplicate=allow_duplicate, allow_conflict=allow_conflict, project=project, + visibility=visibility, review_after=review_after, expires_at=expires_at, ) diff --git a/serve.py b/serve.py index 77f92d9..4763318 100644 --- a/serve.py +++ b/serve.py @@ -571,6 +571,7 @@ def _remember_memory_from_web(payload: dict[str, object]) -> dict[str, object]: _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, + visibility=_clean_text_input(payload.get("visibility"), max_len=30) or None, review_after=_clean_text_input(payload.get("review_after"), max_len=40) or None, expires_at=_clean_text_input(payload.get("expires_at"), max_len=40) or None, records=_memory_records(), diff --git a/tests/test_capture_core.py b/tests/test_capture_core.py index 55796d9..af131b3 100644 --- a/tests/test_capture_core.py +++ b/tests/test_capture_core.py @@ -180,6 +180,7 @@ def test_capture_accept_memory_args_and_payload(self): "memory": "The user prefers release branches.", "memory_type": "preference", "scope": "project", + "visibility": "team", "project": "link", }, } @@ -195,6 +196,7 @@ def test_capture_accept_memory_args_and_payload(self): self.assertEqual(args["title"], "Release branch preference") self.assertEqual(args["memory_type"], "preference") self.assertEqual(args["scope"], "project") + self.assertEqual(args["visibility"], "team") self.assertEqual(args["tags"], "workflow") self.assertEqual(args["source"], "raw/memory-captures/session.md") self.assertEqual(args["project"], "link") diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index 5c19f03..39604e9 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -114,6 +114,7 @@ def test_snapshot_command_options(self): "--output", "/tmp/link-snapshot", "--include-memories", + "--include-private-memories", "--allow-sensitive", "--force", "--title", @@ -125,6 +126,7 @@ def test_snapshot_command_options(self): self.assertEqual(args.target, "/tmp/link") self.assertEqual(args.output, "/tmp/link-snapshot") self.assertTrue(args.include_memories) + self.assertTrue(args.include_private_memories) self.assertTrue(args.allow_sensitive) self.assertTrue(args.force) self.assertEqual(args.title, "Team Link") @@ -266,6 +268,8 @@ def test_memory_choices_are_enforced(self): "preference", "--scope", "user", + "--visibility", + "private", "--review-after", "2026-06-01", "--expires-at", @@ -274,6 +278,7 @@ def test_memory_choices_are_enforced(self): self.assertEqual(args.memory_type, "preference") self.assertEqual(args.scope, "user") + self.assertEqual(args.visibility, "private") self.assertEqual(args.review_after, "2026-06-01") self.assertEqual(args.expires_at, "2026-07-01") with self.assertRaises(SystemExit): @@ -400,6 +405,7 @@ def test_dispatch_routes_snapshot_arguments(self): "--output", "/tmp/snapshot", "--include-memories", + "--include-private-memories", "--allow-sensitive", "--force", "--title", @@ -418,6 +424,7 @@ def snapshot_handler(target, **kwargs): self.assertEqual(calls[0][0], Path("/tmp/link")) self.assertEqual(calls[0][1]["output"], "/tmp/snapshot") self.assertTrue(calls[0][1]["include_memories"]) + self.assertTrue(calls[0][1]["include_private_memories"]) self.assertTrue(calls[0][1]["allow_sensitive"]) self.assertTrue(calls[0][1]["force"]) self.assertEqual(calls[0][1]["title"], "Team Link") @@ -523,6 +530,8 @@ def test_dispatch_routes_accept_capture_arguments(self): "decision", "--scope", "project", + "--visibility", + "team", "--project", "alpha", "--allow-conflict", @@ -542,6 +551,7 @@ def accept_handler(target, capture, **kwargs): self.assertEqual(calls[0][2]["index"], 2) self.assertEqual(calls[0][2]["memory_type"], "decision") self.assertEqual(calls[0][2]["scope"], "project") + self.assertEqual(calls[0][2]["visibility"], "team") self.assertEqual(calls[0][2]["project"], "alpha") self.assertTrue(calls[0][2]["allow_conflict"]) self.assertTrue(calls[0][2]["json_output"]) diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 7a8056a..56d61cc 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -955,11 +955,14 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str self.assertEqual(rebuilds, [True]) self.assertIn('title: "Prefer release branches"', memory_text) self.assertIn("memory_type: preference", memory_text) + self.assertIn("visibility: project", memory_text) self.assertIn('review_after: "2026-08-01"', memory_text) self.assertIn('expires_at: "2026-12-01"', memory_text) self.assertIn("tags: [memory, preference, git, release]", memory_text) self.assertEqual(created["review_after"], "2026-08-01") self.assertEqual(created["expires_at"], "2026-12-01") + self.assertEqual(created["visibility"], "project") + self.assertEqual(memory_records(wiki)[0]["visibility"], "project") self.assertIn("## Source\n\nunit test", memory_text) self.assertIn("[[prefer-release-branches]]", index_text) self.assertEqual(logged[-1][1], "remember") @@ -979,6 +982,7 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str ) self.assertFalse(duplicate["created"]) self.assertTrue(duplicate["duplicate"]) + self.assertEqual(duplicate["visibility"], "project") self.assertEqual(duplicate["candidates"][0]["name"], "prefer-release-branches") conflict = write_memory_page( @@ -1028,6 +1032,43 @@ def log_writer(timestamp: str, operation: str, description: str, lines: list[str self.assertTrue(duplicate_override["duplicate_override"]) self.assertEqual(duplicate_override["name"], "prefer-release-branches-2") + def test_write_memory_page_allows_explicit_team_visibility(self): + root = Path(tempfile.mkdtemp(prefix="link-memory-visibility-")) + wiki = root / "wiki" + wiki.mkdir(parents=True) + + created = write_memory_page( + wiki, + "Team should share release checklist decisions.", + title="Team release checklist", + memory_type="decision", + scope="project", + visibility="team", + tags="release", + source="unit test", + timestamp="2026-05-05T06:00:00Z", + records=[], + ) + + self.assertTrue(created["created"]) + self.assertEqual(created["visibility"], "team") + self.assertIn("visibility: team", (wiki / "memories/team-release-checklist.md").read_text(encoding="utf-8")) + self.assertEqual(memory_profile(memory_records(wiki))["by_visibility"], {"team": 1}) + + with self.assertRaises(ValueError): + write_memory_page( + wiki, + "Bad visibility.", + title="Bad", + memory_type="note", + scope="user", + visibility="public", + tags="", + source="unit test", + timestamp="2026-05-05T06:01:00Z", + records=[], + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_snapshot_core.py b/tests/test_snapshot_core.py index 223b499..74ac568 100644 --- a/tests/test_snapshot_core.py +++ b/tests/test_snapshot_core.py @@ -57,6 +57,22 @@ def setUp(self): "memory", "> **TLDR:** User prefers local memory.\n\nPrivate preference.", ) + private_text = (self.wiki / "memories/prefer-local-memory.md").read_text(encoding="utf-8") + (self.wiki / "memories/prefer-local-memory.md").write_text( + private_text.replace("type: memory\n", "type: memory\nscope: user\nvisibility: private\n"), + encoding="utf-8", + ) + _write_page( + self.wiki / "memories/team-release-plan.md", + "Team release plan", + "memory", + "> **TLDR:** Team-visible release memory.\n\nShared release context.", + ) + team_text = (self.wiki / "memories/team-release-plan.md").read_text(encoding="utf-8") + (self.wiki / "memories/team-release-plan.md").write_text( + team_text.replace("type: memory\n", "type: memory\nscope: project\nvisibility: team\n"), + encoding="utf-8", + ) def test_export_snapshot_excludes_memories_and_raw_by_default(self): output = self.root / "snapshot" @@ -80,7 +96,7 @@ def test_export_snapshot_excludes_memories_and_raw_by_default(self): self.assertNotIn("output", manifest) self.assertNotIn(str(self.root), json.dumps(manifest)) - def test_export_snapshot_can_include_memories_intentionally(self): + def test_export_snapshot_includes_only_non_private_memories_intentionally(self): output = self.root / "snapshot" payload = export_snapshot(self.wiki, output, include_memories=True) @@ -88,6 +104,25 @@ def test_export_snapshot_can_include_memories_intentionally(self): self.assertTrue(payload["created"]) self.assertTrue(payload["include_memories"]) self.assertEqual(payload["page_count"], 3) + self.assertFalse((output / "pages/prefer-local-memory.html").exists()) + self.assertTrue((output / "pages/team-release-plan.html").exists()) + manifest = json.loads((output / "snapshot.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["excluded_counts"]["private_memories"], 1) + + def test_export_snapshot_can_include_private_memories_with_explicit_flag(self): + output = self.root / "snapshot" + + payload = export_snapshot( + self.wiki, + output, + include_memories=True, + include_private_memories=True, + ) + + self.assertTrue(payload["created"]) + self.assertTrue(payload["include_memories"]) + self.assertTrue(payload["include_private_memories"]) + self.assertEqual(payload["page_count"], 4) self.assertTrue((output / "pages/prefer-local-memory.html").exists()) def test_export_snapshot_blocks_secret_looking_wiki_values(self): diff --git a/tests/test_team_sync_core.py b/tests/test_team_sync_core.py index 0db8f7c..a2ad9d3 100644 --- a/tests/test_team_sync_core.py +++ b/tests/test_team_sync_core.py @@ -10,7 +10,14 @@ from link_core.team_sync import build_team_sync_payload, render_team_sync_text # noqa: E402 -def write_memory(root: Path, name: str, *, scope: str = "project", review_status: str = "reviewed") -> None: +def write_memory( + root: Path, + name: str, + *, + scope: str = "project", + visibility: str | None = None, + review_status: str = "reviewed", +) -> None: path = root / "wiki" / "memories" / f"{name}.md" path.parent.mkdir(parents=True, exist_ok=True) path.write_text( @@ -19,8 +26,11 @@ def write_memory(root: Path, name: str, *, scope: str = "project", review_status f"title: {name.replace('-', ' ').title()}", "memory_type: preference", f"scope: {scope}", + f"visibility: {visibility or ('project' if scope == 'project' else 'private')}", "project: link", "status: active", + 'date_captured: "2026-05-01T00:00:00Z"', + "source: unit test", f"review_status: {review_status}", "---", "", @@ -83,7 +93,7 @@ def test_git_workspace_without_raw_protection_warns(self): self.assertFalse(payload["ready"]) self.assertIn("raw/ is not protected", payload["warnings"][0]) - def test_user_scoped_memories_block_team_sync_readiness(self): + def test_private_memories_block_team_sync_readiness(self): root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) (root / "wiki").mkdir() (root / "wiki" / "_link_schema.json").write_text("{}", encoding="utf-8") @@ -93,7 +103,7 @@ def test_user_scoped_memories_block_team_sync_readiness(self): '[remote "origin"]\n\turl = git@example.com:team/link-memory.git\n', encoding="utf-8", ) - write_memory(root, "private-preference", scope="user", review_status="reviewed") + write_memory(root, "private-preference", scope="user", visibility="private", review_status="reviewed") payload = build_team_sync_payload(root) code, text = render_team_sync_text(payload) @@ -101,9 +111,29 @@ def test_user_scoped_memories_block_team_sync_readiness(self): self.assertEqual(code, 0) self.assertFalse(payload["ready"]) self.assertEqual(payload["memory_share"]["user_scoped_count"], 1) + self.assertEqual(payload["memory_share"]["private_visibility_count"], 1) self.assertFalse(payload["memory_share"]["safe_for_team_git"]) - self.assertIn("active user-scoped memories", " ".join(payload["warnings"])) - self.assertIn("1 user-scoped", text) + self.assertIn("active private memories", " ".join(payload["warnings"])) + self.assertIn("1 private", text) + + def test_user_scoped_team_visibility_can_be_intentionally_shared(self): + root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) + (root / "wiki").mkdir() + (root / "wiki" / "_link_schema.json").write_text("{}", encoding="utf-8") + (root / ".gitignore").write_text("raw/*\n", encoding="utf-8") + (root / ".git").mkdir() + (root / ".git" / "config").write_text( + '[remote "origin"]\n\turl = git@example.com:team/link-memory.git\n', + encoding="utf-8", + ) + write_memory(root, "shared-team-preference", scope="user", visibility="team", review_status="reviewed") + + payload = build_team_sync_payload(root) + + self.assertTrue(payload["ready"]) + self.assertEqual(payload["memory_share"]["user_scoped_count"], 1) + self.assertEqual(payload["memory_share"]["team_visibility_count"], 1) + self.assertEqual(payload["memory_share"]["private_visibility_count"], 0) def test_unreviewed_memories_block_team_sync_readiness(self): root = Path(tempfile.mkdtemp(prefix="link-team-sync-")) From 7f37da9cc410ac51ce3a14b1dec9980629280239 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 04:19:51 -0600 Subject: [PATCH 22/35] Document Link memory contract --- CHANGELOG.md | 1 + README.md | 5 ++ docs/api.html | 1 + docs/cli.html | 1 + docs/concepts.html | 1 + docs/contributing.html | 1 + docs/getting-started.html | 1 + docs/index.html | 1 + docs/mcp.html | 2 + docs/memory-contract.html | 132 ++++++++++++++++++++++++++++++++++++++ docs/obsidian.html | 1 + docs/security.html | 1 + docs/team-security.html | 1 + docs/troubleshooting.html | 1 + docs/ui.html | 1 + docs/why-link.html | 1 + 16 files changed, 152 insertions(+) create mode 100644 docs/memory-contract.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e01bc..e53835f 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 `link memory-log`, MCP `memory_log`, `/memory-log`, and `/api/memory-log` for recent memory lifecycle changes without exposing raw source or memory bodies. - Added `link wins`, MCP `memory_wins`, `/wins`, and `/api/wins` for local, non-telemetry proof signals about what Link memory is carrying. - Added a team security review docs page covering local deployment, data boundaries, memory approval gates, Git sharing, audit exports, and current limits. +- Added a memory contract docs page that explains the stable MCP agent loop, tool groups, write rules, budget behavior, and sharing semantics. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/README.md b/README.md index aef71b0..5a9437b 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,11 @@ creating a duplicate. - `memory_wins`: local proof signals for what Link memory is carrying, based on wiki metadata rather than telemetry. +The stable agent-facing loop is documented at +[Link Memory Contract](https://gowtham0992.github.io/link/memory-contract.html): +readiness first, bounded recall, explicit memory writes, audit tools, and +sharing semantics. + Use `review_after` for time-sensitive preferences or decisions. When that date arrives, the memory reappears in Link's review inbox so an agent can ask the user to confirm, update, archive, or forget it instead of trusting stale context. diff --git a/docs/api.html b/docs/api.html index 376a2bc..9e3bc2a 100644 --- a/docs/api.html +++ b/docs/api.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/cli.html b/docs/cli.html index 3b49b2a..8c04d4d 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/concepts.html b/docs/concepts.html index 80eeaf7..10296f0 100644 --- a/docs/concepts.html +++ b/docs/concepts.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/contributing.html b/docs/contributing.html index f954c7c..fcaf0a2 100644 --- a/docs/contributing.html +++ b/docs/contributing.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/getting-started.html b/docs/getting-started.html index 2717112..302c717 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/index.html b/docs/index.html index e1bd811..f702933 100644 --- a/docs/index.html +++ b/docs/index.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/mcp.html b/docs/mcp.html index 826987d..cee920a 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security @@ -61,6 +62,7 @@

    MCP Tour

    MCP does not need serve.py The local web viewer is only for humans. MCP clients start python -m link_mcp --wiki ... over stdio and read the same Markdown wiki directly. +

    For agent builders, the stable read/write behavior is summarized in the Link memory contract.

    Agent Installers

    Use the installer for the agent you use most. Installers create or update ~/link, install link-mcp, write short agent instructions, and preserve existing wiki data.

    diff --git a/docs/memory-contract.html b/docs/memory-contract.html new file mode 100644 index 0000000..8d9546d --- /dev/null +++ b/docs/memory-contract.html @@ -0,0 +1,132 @@ + + + + + + + Link - Memory Contract + + + + + + + + + + + + + +
    +
    + Agent memory contract +

    A predictable local memory interface for agents.

    +

    Link's MCP tools give agents one stable loop: check readiness, get starter prompts, retrieve compact context, write only approved memory, and audit what changed.

    +
    +
    + +
    +
    + +
    +

    Contract Promise

    +

    Link is not asking every agent to learn a new notes app. It gives agents a small, repeatable memory contract backed by local Markdown files.

    +
    +

    Readable storage

    Raw source notes live in raw/. Structured pages and durable memories live in wiki/.

    +

    Bounded recall

    Agents ask for a task-sized packet instead of dumping the whole wiki into context.

    +

    Reviewable writes

    Durable memory writes are explicit, duplicate-checked, conflict-checked, and logged.

    +

    Local trust

    The CLI, HTTP viewer, and MCP server read the same local files. serve.py is not required for MCP.

    +
    + +

    Recommended Agent Loop

    +
      +
    1. Call link_status. If schema or validation needs attention, call the repair tool the payload suggests.
    2. +
    3. Call starter_prompts when a user asks what to try first.
    4. +
    5. Call memory_brief before substantive work.
    6. +
    7. Call query_link for answer-ready context packets.
    8. +
    9. Call ingest_status after the user drops files into raw/.
    10. +
    11. Use propose_memories before writing durable memories from long notes.
    12. +
    13. Use remember_memory only after the user explicitly asks to remember something.
    14. +
    +
    User: is Link ready?
    +Agent: link_status()
    +
    +User: brief me from Link before we continue
    +Agent: memory_brief(query="current task")
    +
    +User: query Link for the release plan
    +Agent: query_link(query="release plan", budget="small")
    + +

    Core Tool Groups

    + + + + + + + + + + +
    NeedPrimary toolsContract behavior
    Readinesslink_status, validate_wiki, link_operationsReturns safe next actions instead of guessing.
    First usestarter_prompts, ingest_statusProvides natural prompts and exact post-ingest checks.
    Recallmemory_brief, query_link, recall_memory, get_contextReturns ranked memory/wiki context with reasons and budget limits.
    Graphget_graph_summary, get_backlinks, get_graphStarts bounded, then asks before expanding to large exports.
    Memory writespropose_memories, remember_memory, update_memory, review_memoryRequires explicit approval and surfaces duplicate/conflict candidates.
    Auditmemory_log, memory_wins, memory_audit, backup_wikiReports lifecycle and proof signals without raw memory bodies.
    + +

    Write Rules

    +

    Agents should treat Link memory as durable state, not scratch space.

    +
      +
    • Do not call remember_memory unless the user explicitly asks to remember something.
    • +
    • Use propose_memories for transcripts, long notes, or uncertain context.
    • +
    • If a write returns duplicate_candidates, update the existing memory instead of creating another one.
    • +
    • If a write returns conflict_candidates, explain the conflict and ask the user before overriding.
    • +
    • Use review_after for memories that should be re-checked, and expires_at for temporary context.
    • +
    + +

    Sharing Semantics

    +

    scope answers where a memory applies. visibility answers who should see it.

    + + + + + + +
    FieldValuesMeaning
    scopeuser, project, globalControls relevance for recall and project filters.
    visibilityprivate, project, teamControls sharing intent for Git sync and snapshots.
    +

    By default, user/global memories are private and project memories are project-visible. link team-sync blocks ready status if private memories would be included in a broad Git share. link snapshot --include-memories still excludes private memories unless --include-private-memories is explicitly passed.

    + +

    Budgets

    +

    query_link supports small, medium, and large budgets. Each packet includes why an item was selected, estimated tokens, has_more flags, and follow-up actions. This keeps agents from reading a 500-page or 10,000-page wiki just to answer one task.

    +
    query_link(query="what should I know before changing release docs?", budget="small")
    +
    +
    +
    + + + + diff --git a/docs/obsidian.html b/docs/obsidian.html index 2caae33..162e5db 100644 --- a/docs/obsidian.html +++ b/docs/obsidian.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/security.html b/docs/security.html index 0badac6..b76c51f 100644 --- a/docs/security.html +++ b/docs/security.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/team-security.html b/docs/team-security.html index 9d0e743..8625d87 100644 --- a/docs/team-security.html +++ b/docs/team-security.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/troubleshooting.html b/docs/troubleshooting.html index aac55de..1af2666 100644 --- a/docs/troubleshooting.html +++ b/docs/troubleshooting.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/ui.html b/docs/ui.html index 6e24d6b..8146a68 100644 --- a/docs/ui.html +++ b/docs/ui.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security diff --git a/docs/why-link.html b/docs/why-link.html index 8a38a74..38855a5 100644 --- a/docs/why-link.html +++ b/docs/why-link.html @@ -24,6 +24,7 @@ UI Concepts MCP + Contract CLI API Security From b393d122d26b4181938859f77f612bdd1ad7004d Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 10:07:13 -0600 Subject: [PATCH 23/35] Document integration maintainer workflow --- CHANGELOG.md | 1 + docs/contributing.html | 4 +++ integrations/README.md | 59 ++++++++++++++++++++++++++++++++++++++++ tests/test_installers.py | 16 +++++++++++ 4 files changed, 80 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e53835f..7fb6241 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 `link wins`, MCP `memory_wins`, `/wins`, and `/api/wins` for local, non-telemetry proof signals about what Link memory is carrying. - Added a team security review docs page covering local deployment, data boundaries, memory approval gates, Git sharing, audit exports, and current limits. - Added a memory contract docs page that explains the stable MCP agent loop, tool groups, write rules, budget behavior, and sharing semantics. +- Added an integration maintainer checklist covering installer invariants, new-agent steps, PowerShell parity, and validation commands. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/docs/contributing.html b/docs/contributing.html index fcaf0a2..2fb64bc 100644 --- a/docs/contributing.html +++ b/docs/contributing.html @@ -50,6 +50,7 @@

    Keep Link local, inspectable, and reliable.

    PR description Project structure Design principles + Installer integrations

    Branches

    @@ -107,6 +108,9 @@

    Design Principles

  • 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.
  • + +

    Installer Integrations

    +

    Agent installers are part of Link's first-use product surface. If a change touches Codex, Claude Code, Kiro, Cursor, Copilot, VS Code, Antigravity, MCP config writing, or PowerShell support, follow the maintainer checklist in integrations/README.md. The key rule is simple: installers should preserve existing user instructions, keep CLI/MCP independent from the web viewer, and update macOS/Linux plus Windows paths together.

    diff --git a/integrations/README.md b/integrations/README.md index 337ce91..09454cc 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -63,3 +63,62 @@ Each folder has an `uninstall.sh`. Same `--project` flag applies. PowerShell uninstall scripts are not needed yet because `install.ps1` only writes the same small instruction/config files listed above; remove those files or delete the `link` MCP entry from the relevant JSON config if you need to undo it manually. + +## Maintainer checklist + +Agent integrations are part of the product surface. Treat installer changes like +runtime changes: they affect the first ten minutes, MCP readiness, and whether an +agent knows how to use Link without wasting context. + +Before changing an installer: + +1. Keep the agent instruction small. It should point the agent to Link tools, + not paste the whole protocol into every prompt file. +2. Preserve existing user instructions. Upsert Link blocks or config entries; + never rewrite the whole agent config file. +3. Keep global and project mode behavior explicit. Global mode uses `~/link/`; + project mode uses the current directory. +4. Keep CLI and MCP independent from the web viewer. `serve.py` is only for the + local UI. CLI commands and MCP tools must work without the server running. +5. Reuse `.link-mcp-python` when possible. MCP clients should run the Python + environment that actually has `link-mcp` installed. +6. Avoid outbound install scripts. Do not add `curl | sh`, telemetry, or hidden + network calls to integration scripts. +7. Update both macOS/Linux and PowerShell installers together when an integration + behavior changes. +8. Update README/docs examples when a new integration or command path is added. + +When adding a new integration: + +1. Create `integrations//install.sh` and `uninstall.sh`. +2. Create `integrations//install.ps1`. +3. Source the shared scaffold and next-step helpers instead of duplicating setup + logic: + + ```bash + . "$SCRIPT_DIR/../_shared/scaffold.sh" + . "$SCRIPT_DIR/../_shared/instructions.sh" + ``` + + ```powershell + . "$PSScriptRoot\..\_shared\scaffold.ps1" + . "$PSScriptRoot\..\_shared\instructions.ps1" + ``` + +4. Add the integration to this table, `README.md`, `docs/getting-started.html`, + and `docs/mcp.html`. +5. Add or update tests in `tests/test_installers.py`. +6. Run the installer checks before opening a PR: + + ```bash + bash -n integrations/*/install.sh integrations/*/uninstall.sh integrations/_shared/*.sh + python3 -m pytest tests/test_installers.py -q + python3 scripts/check_release_hygiene.py + git diff --check + ``` + + On Windows or a machine with PowerShell: + + ```powershell + pwsh -NoProfile -Command "Get-ChildItem integrations -Recurse -Include *.ps1 | ForEach-Object { [scriptblock]::Create((Get-Content -Raw $_.FullName)) | Out-Null }" + ``` diff --git a/tests/test_installers.py b/tests/test_installers.py index d57ed69..1678505 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -120,6 +120,22 @@ def test_windows_installers_are_documented(self): self.assertIn(f".\\integrations\\{name}\\install.ps1", getting_started) self.assertIn(f".\\integrations\\{name}\\install.ps1", mcp) + def test_integration_maintainer_checklist_is_documented(self): + integrations = (ROOT / "integrations/README.md").read_text(encoding="utf-8") + contributing = (ROOT / "docs/contributing.html").read_text(encoding="utf-8") + + for expected in [ + "Maintainer checklist", + "Preserve existing user instructions", + "CLI and MCP independent from the web viewer", + "PowerShell", + "tests/test_installers.py", + ]: + self.assertIn(expected, integrations) + + self.assertIn("integrations/README.md", contributing) + self.assertIn("CLI/MCP independent from the web viewer", contributing) + def test_ci_checks_powershell_installer_syntax(self): workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") From 8bd19a2abe5c30b856d54b7938e0d75ab2eccfb5 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 10:14:47 -0600 Subject: [PATCH 24/35] Document Link scale model --- CHANGELOG.md | 1 + README.md | 4 ++ docs/api.html | 1 + docs/cli.html | 1 + docs/concepts.html | 1 + docs/contributing.html | 1 + docs/getting-started.html | 1 + docs/index.html | 2 + docs/mcp.html | 1 + docs/memory-contract.html | 1 + docs/obsidian.html | 1 + docs/scale.html | 113 ++++++++++++++++++++++++++++++++++++++ docs/security.html | 1 + docs/team-security.html | 1 + docs/troubleshooting.html | 1 + docs/ui.html | 1 + docs/why-link.html | 1 + tests/test_docs_site.py | 6 ++ 18 files changed, 139 insertions(+) create mode 100644 docs/scale.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb6241..e928824 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 team security review docs page covering local deployment, data boundaries, memory approval gates, Git sharing, audit exports, and current limits. - Added a memory contract docs page that explains the stable MCP agent loop, tool groups, write rules, budget behavior, and sharing semantics. - Added an integration maintainer checklist covering installer invariants, new-agent steps, PowerShell parity, and validation commands. +- Added a scale model docs page covering bounded defaults, benchmark/health checks, large-wiki habits, and current local limits. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. diff --git a/README.md b/README.md index 5a9437b..a2c01d6 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,10 @@ python3 scripts/smoke_large_wiki.py --pages 10000 This generates a temporary synthetic wiki, verifies bounded graph/query payloads, and reports cache timing, persistent-cache reuse, search, query, graph, and health signals without touching your real Link wiki. +The public scale model is documented at +[Link Scale](https://gowtham0992.github.io/link/scale.html): what stays +bounded by default, how to measure your own wiki, and where the current local +limits are. ## Three Ways To Use Link diff --git a/docs/api.html b/docs/api.html index 9e3bc2a..16e8e6a 100644 --- a/docs/api.html +++ b/docs/api.html @@ -21,6 +21,7 @@
    First usestarter_prompts, ingest_statusProvides natural prompts and exact post-ingest checks.
    Recallmemory_brief, query_link, recall_memory, get_contextReturns ranked memory/wiki context with reasons and budget limits.
    Graphget_graph_summary, get_backlinks, get_graphStarts bounded, then asks before expanding to large exports.
    Memory writespropose_memories, remember_memory, update_memory, review_memoryRequires explicit approval and surfaces duplicate/conflict candidates.
    Memory writespropose_memories, remember_memory, update_memory, set_memory_visibility, review_memoryRequires explicit approval and surfaces duplicate/conflict candidates.
    Auditmemory_log, memory_wins, memory_audit, backup_wikiReports lifecycle and proof signals without raw memory bodies.
    @@ -115,6 +115,7 @@

    Sharing Semantics

    By default, user/global memories are private and project memories are project-visible. link team-sync blocks ready status if private memories would be included in a broad Git share. link snapshot --include-memories still excludes private memories unless --include-private-memories is explicitly passed.

    +

    Use set_memory_visibility or link set-memory-visibility only after explicit user approval when an existing memory should move between private, project, and team sharing intent.

    Budgets

    query_link supports small, medium, and large budgets. Each packet includes why an item was selected, estimated tokens, has_more flags, and follow-up actions. This keeps agents from reading a 500-page or 10,000-page wiki just to answer one task.

    diff --git a/link.py b/link.py index 3c7827f..1bbcf4b 100644 --- a/link.py +++ b/link.py @@ -125,6 +125,7 @@ recent_memories as _core_recent_memories, resolve_memory_page as _core_resolve_memory_page, set_memory_status as _core_set_memory_status, + set_memory_visibility as _core_set_memory_visibility, top_tags as _core_top_tags, update_memory_page as _core_update_memory_page, write_memory_page as _core_write_memory_page, @@ -199,6 +200,7 @@ render_recall_text as _core_render_recall_text, render_review_memory_text as _core_render_review_memory_text, render_remember_text as _core_render_remember_text, + render_set_memory_visibility_text as _core_render_set_memory_visibility_text, render_update_memory_text as _core_render_update_memory_text, ) from link_core.capture import ( @@ -535,6 +537,23 @@ def _set_memory_status( ) +def _set_memory_visibility( + target: Path, + identifier: str, + visibility: str, + timestamp: str | None = None, +) -> dict[str, object]: + wiki_dir, records = _memory_runtime(target) + return _core_set_memory_visibility( + wiki_dir, + identifier, + visibility, + timestamp=timestamp or _utc_timestamp(), + records=records, + log_writer=_log_writer_for(wiki_dir), + ) + + def _mark_memory_reviewed( target: Path, identifier: str, @@ -1357,6 +1376,25 @@ def update_memory( ) +def set_memory_visibility( + target: Path, + identifier: str, + visibility: str, + json_output: bool = False, +) -> int: + try: + result = _set_memory_visibility(target, identifier, visibility) + except (FileNotFoundError, ValueError) as exc: + print(f"Could not set memory visibility: {exc}", file=sys.stderr) + return 1 + + return _emit_json_or_text( + result, + json_output, + lambda payload: _core_render_set_memory_visibility_text(payload, target=target), + ) + + def recall( target: Path, query: str, @@ -1996,6 +2034,7 @@ def main(argv: list[str] | None = None) -> int: "redact-capture": redact_capture, "delete-capture": delete_capture, "update-memory": update_memory, + "set-memory-visibility": set_memory_visibility, "recall": recall, "query": query, "graph-summary": graph_summary, diff --git a/mcp_package/README.md b/mcp_package/README.md index 1c55468..92fc147 100644 --- a/mcp_package/README.md +++ b/mcp_package/README.md @@ -155,6 +155,7 @@ In the local web proposal picker, unreadable raw files are surfaced as | `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. | +| `set_memory_visibility(identifier, visibility)` | Change a memory's sharing intent between `private`, `project`, and `team` after explicit user approval. | | `archive_memory(identifier, reason?)` | Archive stale or wrong memory without deleting the Markdown page. | | `restore_memory(identifier)` | Restore archived memory to active status. | | `forget_memory(identifier, confirm?)` | Permanently delete a memory only after explicit user confirmation; prefer archive for reversible cleanup. | diff --git a/mcp_package/link_core/cli_memory.py b/mcp_package/link_core/cli_memory.py index 0ae7560..d7e6d78 100644 --- a/mcp_package/link_core/cli_memory.py +++ b/mcp_package/link_core/cli_memory.py @@ -141,6 +141,22 @@ def render_update_memory_text(result: Mapping[str, object], *, target: object = ]) +def render_set_memory_visibility_text(result: Mapping[str, object], *, target: object = ".") -> tuple[int, str]: + headline = "Memory visibility updated" if result.get("updated") else "Memory visibility already set" + return 0, "\n".join([ + headline, + f"Title: {result['title']}", + f"Path: {result['path']}", + f"Scope: {result['scope']}", + f"Visibility: {result['previous_visibility']} -> {result['visibility']}", + f"Review: {result.get('review_status', 'pending')}", + "", + "Next:", + f" {_shell_words('python3', 'link.py', 'explain-memory', result['name'], target)}", + f" {_shell_words('python3', 'link.py', 'team-sync', target)}", + ]) + + def render_propose_memories_text(result: Mapping[str, object]) -> tuple[int, str]: proposals = result.get("proposals") if not isinstance(proposals, Sequence) or isinstance(proposals, (str, bytes)): diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index aaa2210..aed7216 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -197,6 +197,12 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum 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") + visibility_cmd = sub.add_parser("set-memory-visibility", help="change a memory sharing visibility") + visibility_cmd.add_argument("identifier", help="memory page name, title, or path") + visibility_cmd.add_argument("visibility", choices=MEMORY_VISIBILITIES, help="new visibility: private, project, or team") + visibility_cmd.add_argument("target", nargs="?", default=".") + visibility_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=".") @@ -478,6 +484,13 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: project=args.project, json_output=args.json, ) + if command == "set-memory-visibility": + return handlers["set-memory-visibility"]( + Path(args.target), + args.identifier, + args.visibility, + json_output=args.json, + ) if command == "recall": return handlers["recall"]( Path(args.target), diff --git a/mcp_package/link_core/memory.py b/mcp_package/link_core/memory.py index 6b6cbfd..24b17db 100644 --- a/mcp_package/link_core/memory.py +++ b/mcp_package/link_core/memory.py @@ -970,6 +970,57 @@ def set_memory_status( } +def set_memory_visibility( + wiki_dir: Path, + identifier: str, + visibility: str, + timestamp: str, + records: Iterable[Mapping[str, object]] | None = None, + log_writer: MemoryLogWriter | None = None, +) -> dict[str, object]: + page_path, record, error = resolve_memory_page(wiki_dir, identifier, records=records) + if error: + raise ValueError(error) + assert page_path is not None and record is not None + + scope = str(record.get("scope") or "user").lower() + clean_visibility = normalize_memory_visibility(scope, visibility) + previous_visibility = str(record.get("visibility") or default_memory_visibility(scope)) + changed = previous_visibility != clean_visibility + if changed: + with operation_journal( + wiki_dir, + "set-memory-visibility", + str(record["title"]), + timestamp=timestamp, + paths=[f"wiki/memories/{page_path.name}", "wiki/log.md"], + ): + text = page_path.read_text(encoding="utf-8", errors="replace") + atomic_write_text(page_path, update_frontmatter_fields(text, {"visibility": clean_visibility})) + if log_writer: + log_writer( + timestamp, + "set-memory-visibility", + str(record["title"]), + [ + f"Updated: memories/{page_path.name}", + f"Previous visibility: {previous_visibility}", + f"New visibility: {clean_visibility}", + ], + ) + + return { + "updated": changed, + "name": record["name"], + "path": record["path"], + "title": record["title"], + "scope": scope, + "previous_visibility": previous_visibility, + "visibility": clean_visibility, + "review_status": record.get("review_status", "pending"), + } + + def forget_memory_page( wiki_dir: Path, identifier: str, diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 4a84c90..4d59ed9 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -130,6 +130,7 @@ recent_memories as _core_recent_memories, resolve_memory_page as _core_resolve_memory_page, set_memory_status as _core_set_memory_status, + set_memory_visibility as _core_set_memory_visibility, slim_memory as _core_slim_memory, top_tags as _core_top_tags, update_memory_page as _core_update_memory_page, @@ -715,6 +716,20 @@ def _set_memory_status(identifier: str, status: str, reason: str = "") -> dict[s return result +def _set_memory_visibility(identifier: str, visibility: str) -> dict[str, object]: + result = _core_set_memory_visibility( + WIKI_DIR, + _clean_text_input(identifier, max_len=300), + _clean_text_input(visibility, max_len=40), + timestamp=_utc_timestamp(), + records=_memory_records(), + log_writer=_append_log, + ) + if result["updated"]: + _clear_cache() + return result + + def _forget_memory(identifier: str, confirm: bool = False) -> dict[str, object]: result = _core_forget_memory_page( WIKI_DIR, @@ -1179,6 +1194,21 @@ def update_memory( return json.dumps(result, ensure_ascii=False) +@mcp.tool() +def set_memory_visibility(identifier: str, visibility: str) -> str: + """Change a memory's sharing visibility. + + Use this after explicit user approval when a memory should move between + private, project, and team visibility. This updates frontmatter and logs the + visibility change; it does not expose raw sources or memory bodies in logs. + """ + try: + result = _set_memory_visibility(identifier, visibility) + except ValueError as exc: + return json.dumps({"updated": False, "error": str(exc)}) + return json.dumps(result, ensure_ascii=False) + + @mcp.tool() def archive_memory(identifier: str, reason: str = "") -> str: """Archive a memory without deleting its Markdown page. diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 50e50a9..3d119de 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -48,6 +48,7 @@ "restore-memory", "review-memory", "serve", + "set-memory-visibility", "share", "snapshot", "status", @@ -94,6 +95,7 @@ "restore_memory", "review_memory", "search_wiki", + "set_memory_visibility", "starter_prompts", "update_memory", "validate_wiki", diff --git a/tests/test_cli_memory_core.py b/tests/test_cli_memory_core.py index c6f81cf..1732aec 100644 --- a/tests/test_cli_memory_core.py +++ b/tests/test_cli_memory_core.py @@ -12,6 +12,7 @@ render_recall_text, render_review_memory_text, render_remember_text, + render_set_memory_visibility_text, render_update_memory_text, ) @@ -91,6 +92,23 @@ def test_render_update_memory(self): self.assertIn("Review: reviewed -> pending", text) self.assertIn("python3 link.py review-memory prefer-release-branches", text) + def test_render_set_memory_visibility_text(self): + code, text = render_set_memory_visibility_text({ + "updated": True, + "name": "prefer-release-branches", + "title": "Prefer release branches", + "path": "wiki/memories/prefer-release-branches.md", + "scope": "project", + "previous_visibility": "private", + "visibility": "team", + "review_status": "reviewed", + }) + + self.assertEqual(code, 0) + self.assertIn("Memory visibility updated", text) + self.assertIn("Visibility: private -> team", text) + self.assertIn("python3 link.py team-sync", text) + def test_render_propose_memories_text(self): code, text = render_propose_memories_text({ "source": "raw/session.md", diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index 39604e9..c9be13b 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -518,6 +518,29 @@ def prompts_handler(target, **kwargs): self.assertEqual(calls[0][1]["project"], "alpha") self.assertTrue(calls[0][1]["json_output"]) + def test_dispatch_routes_set_memory_visibility_arguments(self): + parser = build_cli_parser() + args = parser.parse_args([ + "set-memory-visibility", + "prefer-local-memory", + "team", + "/tmp/link", + "--json", + ]) + calls = [] + + def visibility_handler(target, identifier, visibility, **kwargs): + calls.append((target, identifier, visibility, kwargs)) + return 4 + + code = dispatch_cli_command(args, {"set-memory-visibility": visibility_handler}) + + self.assertEqual(code, 4) + self.assertEqual(calls[0][0], Path("/tmp/link")) + self.assertEqual(calls[0][1], "prefer-local-memory") + self.assertEqual(calls[0][2], "team") + self.assertTrue(calls[0][3]["json_output"]) + def test_dispatch_routes_accept_capture_arguments(self): parser = build_cli_parser() args = parser.parse_args([ diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 2929573..2e4f1e0 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -1048,6 +1048,32 @@ def test_update_memory_merges_text_and_resets_review(self): self.assertIn("update-memory", log_text) self.assertIn("prefer-local-personal-memory", backlinks["backlinks"]["link"]) + def test_set_memory_visibility_updates_frontmatter_and_log(self): + tmp = Path(tempfile.mkdtemp(prefix="link-memory-visibility-cli-")) + target = tmp / "demo" + create_demo_quiet(target) + + out = StringIO() + with redirect_stdout(out): + code = link_cli.set_memory_visibility( + target, + "prefer-local-personal-memory", + "team", + json_output=True, + ) + payload = json.loads(out.getvalue()) + memory_text = (target / "wiki/memories/prefer-local-personal-memory.md").read_text(encoding="utf-8") + log_text = (target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertEqual(code, 0) + self.assertTrue(payload["updated"]) + self.assertEqual(payload["previous_visibility"], "private") + self.assertEqual(payload["visibility"], "team") + self.assertIn("visibility: team", memory_text) + self.assertIn("set-memory-visibility", log_text) + self.assertIn("Previous visibility: private", log_text) + self.assertIn("New visibility: team", log_text) + def test_propose_memories_from_session_note_without_writing(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 b30f599..8e7179f 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -903,6 +903,23 @@ def test_update_memory_contract(self): self.assertNotIn("reviewed_at:", memory_text) self.assertIn("update-memory", log_text) + def test_set_memory_visibility_contract(self): + updated = json.loads(self.server.set_memory_visibility("prefer-local-personal-memory", "team")) + unchanged = json.loads(self.server.set_memory_visibility("prefer-local-personal-memory", "team")) + rejected = json.loads(self.server.set_memory_visibility("prefer-local-personal-memory", "public")) + memory_text = (self.target / "wiki/memories/prefer-local-personal-memory.md").read_text(encoding="utf-8") + log_text = (self.target / "wiki/log.md").read_text(encoding="utf-8") + + self.assertTrue(updated["updated"]) + self.assertEqual(updated["previous_visibility"], "private") + self.assertEqual(updated["visibility"], "team") + self.assertFalse(unchanged["updated"]) + self.assertEqual(unchanged["visibility"], "team") + self.assertFalse(rejected["updated"]) + self.assertIn("visibility must be one of", rejected["error"]) + self.assertIn("visibility: team", memory_text) + self.assertIn("set-memory-visibility", 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.", diff --git a/tests/test_memory_core.py b/tests/test_memory_core.py index 56d61cc..345c67c 100644 --- a/tests/test_memory_core.py +++ b/tests/test_memory_core.py @@ -29,6 +29,7 @@ recall_state, resolve_memory_page, set_memory_status, + set_memory_visibility, update_memory_page, write_memory_page, ) @@ -891,6 +892,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") + visibility = set_memory_visibility( + wiki, + "prefer-focused-commits", + "team", + timestamp="2026-05-05T05:30:00Z", + records=memory_records(wiki), + log_writer=log_writer, + ) + visibility_text = memory_path.read_text(encoding="utf-8") + + self.assertTrue(visibility["updated"]) + self.assertEqual(visibility["previous_visibility"], "project") + self.assertEqual(visibility["visibility"], "team") + self.assertIn("visibility: team", visibility_text) + self.assertEqual(logged[-1][1], "set-memory-visibility") + + unchanged_visibility = set_memory_visibility( + wiki, + "prefer-focused-commits", + "team", + timestamp="2026-05-05T05:35:00Z", + records=memory_records(wiki), + log_writer=log_writer, + ) + + self.assertFalse(unchanged_visibility["updated"]) + self.assertEqual(unchanged_visibility["visibility"], "team") + (wiki / "index.md").write_text("### memories\n- [[prefer-focused-commits]] - old entry\n", encoding="utf-8") denied = forget_memory_page( wiki, From 633785098ea49f54de9391160420a9b593d09124 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 10:44:47 -0600 Subject: [PATCH 26/35] Scan wiki pages for secret values --- CHANGELOG.md | 1 + README.md | 4 +++- docs/security.html | 4 +++- mcp_package/link_core/doctor.py | 1 + mcp_package/link_core/validation.py | 13 +++++++++++++ tests/test_doctor_core.py | 4 +++- tests/test_validation_core.py | 24 ++++++++++++++++++++++++ 7 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c452d04..1e1d11b 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 a scale model docs page covering bounded defaults, benchmark/health checks, large-wiki habits, and current local limits. - Added `python -m link_mcp --version` so MCP package installs can be verified before a wiki exists. - Added an Obsidian guide for opening Link's Markdown wiki as a vault and rebuilding indexes after manual edits. +- Added validation and doctor failures for secret-looking values already present in wiki pages so local UI and MCP context do not quietly serve manually introduced secrets. ### Changed diff --git a/README.md b/README.md index 6966b93..fba8574 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,9 @@ Link itself is local-first: - `link backup` excludes `raw/` unless you explicitly pass `--include-raw`. - Secret-looking API keys, provider tokens, JWTs, registry credentials, and private key blocks are detected in raw sources, captures, and release hygiene - checks. + checks. `link validate` and `link doctor` also fail if secret-looking values + are found inside wiki pages before they can be served through the local UI or + returned through MCP context. - The local web server binds to `127.0.0.1` and is not meant to be exposed to the internet without additional auth. diff --git a/docs/security.html b/docs/security.html index 63e7c93..784fa09 100644 --- a/docs/security.html +++ b/docs/security.html @@ -66,10 +66,12 @@

    Privacy Model

    The public GitHub Pages documentation may use lightweight analytics to understand install interest. It does not run inside Link, read local wiki data, or capture source/memory content.

    Secret Handling

    -

    Link scans raw sources, captures, release files, and public artifacts for secret-looking values. It detects common API keys, provider tokens, JWTs, private key blocks, and registry credentials, warns without logging secret values, and refuses normal ingest guidance when raw safety cannot be established.

    +

    Link scans raw sources, captures, wiki pages, release files, and public artifacts for secret-looking values. It detects common API keys, provider tokens, JWTs, private key blocks, and registry credentials, warns without logging secret values, and refuses normal ingest guidance when raw safety cannot be established. Validation and doctor checks also fail if a secret-looking value is already present in a wiki page before the local UI or MCP tools can serve it as context.

    link ingest-status
     link capture-inbox
     link redact-capture raw/memory-captures/<capture>.md
    +link validate
    +link doctor
     python3 scripts/check_release_hygiene.py
    Rule diff --git a/mcp_package/link_core/doctor.py b/mcp_package/link_core/doctor.py index 8b8e6f8..3037ddb 100644 --- a/mcp_package/link_core/doctor.py +++ b/mcp_package/link_core/doctor.py @@ -25,6 +25,7 @@ "missing_frontmatter", "missing_frontmatter_field", "missing_required_section", + "secret_value", "type_directory_mismatch", "unreadable_page", } diff --git a/mcp_package/link_core/validation.py b/mcp_package/link_core/validation.py index a36b817..eb04b57 100644 --- a/mcp_package/link_core/validation.py +++ b/mcp_package/link_core/validation.py @@ -7,6 +7,7 @@ from typing import Any from .frontmatter import parse_frontmatter +from .security import secret_value_warnings from .wiki import WIKILINK_RE, load_backlinks_index @@ -132,6 +133,18 @@ def validate_wiki(wiki_dir: Path, *, strict: bool = False) -> dict[str, Any]: unreadable_pages.add(rel) findings.append(_finding("error", "unreadable_page", rel, f"Could not read wiki page: {exc}")) continue + secret_labels = secret_value_warnings(text) + if secret_labels: + label = secret_labels[0] + extra = f" and {len(secret_labels) - 1} more type(s)" if len(secret_labels) > 1 else "" + findings.append( + _finding( + "error", + "secret_value", + rel, + f"Secret-looking value detected in wiki page ({label}{extra}); redact before serving or querying it.", + ) + ) _add_links_to_index( page.stem.lower(), text, diff --git a/tests/test_doctor_core.py b/tests/test_doctor_core.py index b7b674f..8ead000 100644 --- a/tests/test_doctor_core.py +++ b/tests/test_doctor_core.py @@ -59,6 +59,7 @@ def test_validation_errors_are_filtered_to_doctor_codes(self): payload = { "findings": [ {"severity": "error", "code": "missing_required_section", "path": "sources/a.md", "message": "bad"}, + {"severity": "error", "code": "secret_value", "path": "sources/token.md", "message": "redact"}, {"severity": "error", "code": "dead_wikilink", "path": "concepts/b.md", "message": "missing"}, {"severity": "warning", "code": "missing_summary", "path": "sources/c.md", "message": "warn"}, ] @@ -67,8 +68,9 @@ def test_validation_errors_are_filtered_to_doctor_codes(self): findings = doctor_validation_errors(payload) summary = format_validation_error_summary(findings) - self.assertEqual(len(findings), 1) + self.assertEqual(len(findings), 2) self.assertIn("sources/a.md [missing_required_section] bad", summary) + self.assertIn("sources/token.md [secret_value] redact", summary) self.assertNotIn("dead_wikilink", summary) def test_join_limited_caps_items(self): diff --git a/tests/test_validation_core.py b/tests/test_validation_core.py index 657c6e9..4ab702a 100644 --- a/tests/test_validation_core.py +++ b/tests/test_validation_core.py @@ -177,6 +177,30 @@ def flaky_read_text(path: Path, *args, **kwargs): self.assertIn("unreadable_page", {finding["code"] for finding in payload["findings"]}) self.assertNotIn("stale_backlinks", {finding["code"] for finding in payload["findings"]}) + def test_validate_wiki_rejects_secret_values_without_echoing_them(self): + wiki = self.make_wiki() + fake_key = "sk-" + ("A" * 24) + write_page( + wiki, + "sources/leaky-source.md", + "---\ntype: source\ntitle: Leaky Source\n---\n\n" + "# Leaky Source\n\n" + "> **TLDR:** A source with a secret-looking value.\n\n" + "## Summary\n\nDo not keep this token here.\n\n" + f"{fake_key}\n\n" + "## Raw Source\n\n`raw/leaky-source.md`\n", + ) + (wiki / "_backlinks.json").write_text(json.dumps(build_backlinks(wiki, body_only=False)), encoding="utf-8") + + payload = validate_wiki(wiki) + secret_findings = [finding for finding in payload["findings"] if finding["code"] == "secret_value"] + + self.assertFalse(payload["passed"]) + self.assertEqual(len(secret_findings), 1) + self.assertEqual(secret_findings[0]["path"], "sources/leaky-source.md") + self.assertIn("OpenAI API key", secret_findings[0]["message"]) + self.assertNotIn(fake_key, secret_findings[0]["message"]) + if __name__ == "__main__": unittest.main() From 1c20afb2852ac79a715a697cbc10be9b4a8021e5 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 10:55:14 -0600 Subject: [PATCH 27/35] Summarize memory log changes --- CHANGELOG.md | 1 + mcp_package/link_core/cli_memory.py | 10 ++++ mcp_package/link_core/memory_log.py | 57 +++++++++++++++++++++++ mcp_package/link_core/web_memory_pages.py | 14 ++++++ tests/test_memory_log_core.py | 24 ++++++++++ 5 files changed, 106 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1d11b..676bc76 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 memory `visibility` metadata (`private`, `project`, or `team`) so team sharing can rely on explicit user intent instead of inferring privacy from scope alone. - Added `link set-memory-visibility` and MCP `set_memory_visibility` so existing memories can move between private, project, and team sharing intent after explicit user approval. - Added `link memory-log`, MCP `memory_log`, `/memory-log`, and `/api/memory-log` for recent memory lifecycle changes without exposing raw source or memory bodies. +- Added privacy-safe memory-log change summaries so review, status, and visibility transitions are visible without exposing memory bodies. - Added `link wins`, MCP `memory_wins`, `/wins`, and `/api/wins` for local, non-telemetry proof signals about what Link memory is carrying. - Added a team security review docs page covering local deployment, data boundaries, memory approval gates, Git sharing, audit exports, and current limits. - Added a memory contract docs page that explains the stable MCP agent loop, tool groups, write rules, budget behavior, and sharing semantics. diff --git a/mcp_package/link_core/cli_memory.py b/mcp_package/link_core/cli_memory.py index d7e6d78..f884af2 100644 --- a/mcp_package/link_core/cli_memory.py +++ b/mcp_package/link_core/cli_memory.py @@ -375,6 +375,16 @@ def render_memory_log_text(payload: Mapping[str, object], *, target: object) -> summary = str(entry.get("summary") or "").strip() if summary: lines.append(f" {summary}") + impact = str(entry.get("impact") or "").strip() + if impact: + lines.append(f" Impact: {impact}") + changes = entry.get("changes", []) + if isinstance(changes, Sequence) and not isinstance(changes, (str, bytes)): + for change in changes[:4]: + if isinstance(change, Mapping): + lines.append( + f" Change: {change.get('field', '')} {change.get('from', '')} -> {change.get('to', '')}" + ) paths = entry.get("memory_paths", []) if isinstance(paths, Sequence) and not isinstance(paths, (str, bytes)) and paths: lines.append(" Memories: " + ", ".join(str(path) for path in paths)) diff --git a/mcp_package/link_core/memory_log.py b/mcp_package/link_core/memory_log.py index 97b4e30..12d1b3e 100644 --- a/mcp_package/link_core/memory_log.py +++ b/mcp_package/link_core/memory_log.py @@ -15,6 +15,7 @@ "remember", "restore-memory", "review-memory", + "set-memory-visibility", "update-memory", } CAPTURE_OPERATIONS = { @@ -80,6 +81,7 @@ def _memory_log_entry(entry: Mapping[str, object]) -> dict[str, object]: memory_paths = _memory_paths(entry, details) category = "capture" if operation in CAPTURE_OPERATIONS else "memory" details = _safe_details(operation, details) + changes = _state_changes(details) return { "timestamp": str(entry.get("timestamp") or ""), "operation": operation, @@ -87,6 +89,8 @@ def _memory_log_entry(entry: Mapping[str, object]) -> dict[str, object]: "description": str(entry.get("description") or ""), "memory_paths": memory_paths, "details": details, + "changes": changes, + "impact": _impact(operation, changes), "summary": _summary(operation, str(entry.get("description") or ""), memory_paths), } @@ -131,6 +135,57 @@ def _safe_details(operation: str, details: list[str]) -> list[str]: return safe[:8] +def _state_changes(details: list[str]) -> list[dict[str, str]]: + previous: dict[str, str] = {} + current: dict[str, str] = {} + for detail in details: + key, sep, value = detail.partition(":") + if not sep: + continue + key = key.strip().lower() + value = value.strip() + if key.startswith("previous "): + previous[key.removeprefix("previous ").strip()] = value + elif key.startswith("new "): + current[key.removeprefix("new ").strip()] = value + changes: list[dict[str, str]] = [] + for field in sorted(previous.keys() | current.keys()): + before = previous.get(field, "") + after = current.get(field, "") + if before or after: + changes.append({ + "field": field, + "from": before, + "to": after, + }) + return changes + + +def _impact(operation: str, changes: list[dict[str, str]]) -> str: + if operation == "remember": + return "New durable memory is pending review before default trust." + if operation == "update-memory": + return "Memory changed and returned to review before agents rely on it." + if operation == "review-memory": + return "Memory is now reviewed for normal recall." + if operation == "archive-memory": + return "Memory is hidden from default recall without deleting the page." + if operation == "restore-memory": + return "Memory is available for recall again." + if operation == "forget-memory": + return "Memory page was permanently deleted after confirmation." + if operation == "set-memory-visibility": + for change in changes: + if change.get("field") == "visibility": + return f"Sharing intent changed from {change.get('from') or 'unset'} to {change.get('to') or 'unset'}." + return "Memory sharing intent changed." + if operation == "accept-capture": + return "A reviewed capture proposal became durable memory." + if operation in CAPTURE_OPERATIONS: + return "Raw capture lifecycle changed; capture contents are not exposed here." + return "" + + def _summary(operation: str, description: str, memory_paths: list[str]) -> str: target = memory_paths[0] if memory_paths else description if operation == "remember": @@ -147,6 +202,8 @@ def _summary(operation: str, description: str, memory_paths: list[str]) -> str: return f"Restored memory: {target}" if operation == "forget-memory": return f"Permanently forgot memory: {target}" + if operation == "set-memory-visibility": + return f"Changed memory visibility: {target}" if operation == "capture-session": return f"Captured proposal-only notes: {description}" if operation == "redact-capture": diff --git a/mcp_package/link_core/web_memory_pages.py b/mcp_package/link_core/web_memory_pages.py index 7858385..f7e3ccf 100644 --- a/mcp_package/link_core/web_memory_pages.py +++ b/mcp_package/link_core/web_memory_pages.py @@ -509,12 +509,26 @@ def _render_memory_log_item(entry: Mapping[str, object]) -> str: f"
  • {html.escape(str(detail))}
  • " for detail in details[:4] ) + "" + changes = _dict_list(entry.get("changes")) + change_html = "" + if changes: + change_html = "
      " + "".join( + "
    • " + f"{html.escape(str(change.get('field') or 'field'))}: " + f"{html.escape(str(change.get('from') or 'unset'))} -> {html.escape(str(change.get('to') or 'unset'))}" + "
    • " + for change in changes[:4] + ) + "
    " + impact = str(entry.get("impact") or "") + impact_html = f'

    {html.escape(impact)}

    ' if impact else "" return ( "
  • " f"{html.escape(str(entry.get('operation') or 'event'))}" f"
    {html.escape(str(entry.get('timestamp') or ''))} · {html.escape(str(entry.get('category') or 'memory'))}
    " f"

    {html.escape(str(entry.get('description') or ''))}

    " f"{html.escape(str(entry.get('summary') or ''))}" + f"{impact_html}" + f"{change_html}" f"{path_html}" f"{detail_html}" "
  • " diff --git a/tests/test_memory_log_core.py b/tests/test_memory_log_core.py index 169fcee..8b921c4 100644 --- a/tests/test_memory_log_core.py +++ b/tests/test_memory_log_core.py @@ -32,6 +32,7 @@ def test_memory_log_filters_lifecycle_entries(self): entry = payload["entries"][0] self.assertEqual(entry["operation"], "remember") self.assertEqual(entry["memory_paths"], ["wiki/memories/prefer-local-memory.md"]) + self.assertEqual(entry["impact"], "New durable memory is pending review before default trust.") self.assertIn("Memory bodies", payload["privacy_note"]) def test_memory_log_can_hide_capture_events(self): @@ -62,6 +63,29 @@ def test_memory_log_includes_nonstandard_operations_that_touch_memories(self): self.assertEqual(payload["entries"][0]["operation"], "demo") self.assertEqual(payload["entries"][0]["memory_paths"], ["wiki/memories/prefer-local-memory.md"]) + def test_memory_log_extracts_privacy_safe_state_changes(self): + root = Path(tempfile.mkdtemp(prefix="link-memory-log-")) + wiki = root / "wiki" + wiki.mkdir(parents=True) + append_log( + wiki, + "2026-05-25T00:00:00Z", + "set-memory-visibility", + "Prefer local memory", + [ + "Updated: memories/prefer-local-memory.md", + "Previous visibility: private", + "New visibility: team", + ], + ) + + payload = memory_log_payload(wiki) + entry = payload["entries"][0] + + self.assertEqual(entry["summary"], "Changed memory visibility: wiki/memories/prefer-local-memory.md") + self.assertEqual(entry["impact"], "Sharing intent changed from private to team.") + self.assertEqual(entry["changes"], [{"field": "visibility", "from": "private", "to": "team"}]) + if __name__ == "__main__": unittest.main() From e8641b58eaf19fc56541d9e29073cb5dfb83aaf6 Mon Sep 17 00:00:00 2001 From: Gowtham Date: Tue, 26 May 2026 11:16:38 -0600 Subject: [PATCH 28/35] Add safe backup restore command --- CHANGELOG.md | 1 + README.md | 5 + docs/cli.html | 2 + link.py | 39 ++++++++ mcp_package/link_core/backup.py | 142 +++++++++++++++++++++++++++- mcp_package/link_core/cli_admin.py | 28 +++++- mcp_package/link_core/cli_parser.py | 17 ++++ scripts/check_tool_contract.py | 1 + tests/test_backup_core.py | 49 +++++++++- tests/test_cli_parser_core.py | 21 ++++ 10 files changed, 302 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 676bc76..7ac7fa1 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 optional `expires_at` dates for durable memories so temporary context automatically leaves default recall after expiry. - Added `link import-obsidian ` to copy Obsidian Markdown notes into `raw/obsidian/` with secret scanning before the normal ingest workflow. - Added `link compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review. +- Added `link restore-backup` to preview and confirm local backup restores with unsafe-tar checks, raw restore opt-in, and pre-restore safety backups. - Added `link team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically. - Added `link share ` to print a local viewer permalink and agent prompt for a specific Link page. - Added `link snapshot` to export a static, read-only HTML snapshot for demos or reviews while excluding raw sources, captures, live state, and memory pages by default. diff --git a/README.md b/README.md index fba8574..0c1a025 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,11 @@ For day-to-day auditability, `link memory-log ~/link` shows what Link recently remembered, updated, reviewed, archived, restored, forgot, or accepted from raw captures. +For recovery, `link backup ~/link` creates a local archive and `link +restore-backup ~/link` previews what would be restored. Passing +`--confirm` replaces local files after creating a safety backup when possible; +`raw/` is still excluded unless `--include-raw` is explicit. + For local proof of value, `link wins ~/link` shows reusable memories, reviewed memory, provenance, project continuity, freshness guardrails, and copyable prompts without tracking user behavior. diff --git a/docs/cli.html b/docs/cli.html index cb64f75..16c38d0 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -171,6 +171,8 @@

    All Commands

    link rebuild-backlinks link verify-mcp [--json] link connect <agent> [dir] [--write] [--config path] [--python python] +link backup [--list] [--include-raw] +link restore-backup <backup.tar.gz> [--include-raw] --confirm python3 link.py demo python3 link.py query-link "task" [dir]

    query-link is kept as an internal/backward-compatible alias. Prefer link query in user-facing docs.

    diff --git a/link.py b/link.py index 1bbcf4b..18a7e44 100644 --- a/link.py +++ b/link.py @@ -12,6 +12,7 @@ python link.py health [target] python link.py operations [target] python link.py backup [target] + python link.py restore-backup [target] python link.py compliance-export [target] python link.py team-sync [target] python link.py share [target] @@ -132,8 +133,10 @@ ) from link_core.backup import ( BackupError as _CoreBackupError, + RestoreError as _CoreRestoreError, create_backup as _core_create_backup, list_backups as _core_list_backups, + restore_backup as _core_restore_backup, ) from link_core.audit_export import ( build_compliance_export as _core_build_compliance_export, @@ -180,6 +183,7 @@ from link_core.cli_admin import ( render_backup_created_text as _core_render_backup_created_text, render_backup_list_text as _core_render_backup_list_text, + render_backup_restore_text as _core_render_backup_restore_text, render_migrate_text as _core_render_migrate_text, render_rebuild_backlinks_text as _core_render_rebuild_backlinks_text, render_rebuild_index_text as _core_render_rebuild_index_text, @@ -802,6 +806,40 @@ def backup( return code +def restore_backup( + target: Path, + backup: str, + *, + include_raw: bool = False, + confirm: bool = False, + safety_backup: bool = True, + json_output: bool = False, +) -> int: + target = _resolve_link_root(target) + try: + payload = _core_restore_backup( + target, + backup, + include_raw=include_raw, + confirm=confirm, + safety_backup=safety_backup, + ) + except (FileNotFoundError, _CoreBackupError, _CoreRestoreError) as exc: + if json_output: + print(json.dumps({"restored": False, "error": str(exc)}, indent=2)) + else: + print(str(exc), file=sys.stderr) + return 1 + + if json_output: + print(json.dumps(payload, indent=2)) + return 0 if payload.get("restored") or payload.get("confirmation_required") else 1 + + code, text = _core_render_backup_restore_text(payload, target=target) + print(text) + return code + + def compliance_export( target: Path, output: str | None = None, @@ -2017,6 +2055,7 @@ def main(argv: list[str] | None = None) -> int: "health": health, "operations": operations, "backup": backup, + "restore-backup": restore_backup, "compliance-export": compliance_export, "team-sync": team_sync, "share": share, diff --git a/mcp_package/link_core/backup.py b/mcp_package/link_core/backup.py index dbc924b..dc55087 100644 --- a/mcp_package/link_core/backup.py +++ b/mcp_package/link_core/backup.py @@ -2,9 +2,11 @@ from __future__ import annotations import re +import shutil import tarfile +import tempfile from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any @@ -16,6 +18,10 @@ class BackupError(RuntimeError): """Raised when a backup archive cannot be completed safely.""" +class RestoreError(RuntimeError): + """Raised when a backup archive cannot be restored safely.""" + + def _utc_timestamp() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -166,3 +172,137 @@ def list_backups(link_root: Path, *, limit: int = 20) -> dict[str, Any]: "warnings": warnings, "backups": backups, } + + +def _resolve_backup_path(root: Path, backup: str | Path) -> Path: + value = Path(str(backup)).expanduser() + candidates = [value] + if not value.is_absolute(): + candidates.append(root / BACKUP_DIR_NAME / value) + for candidate in candidates: + path = candidate.resolve() + if path.exists() and path.is_file(): + return path + return candidates[-1].resolve() + + +def _safe_member_path(name: str) -> PurePosixPath: + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts: + raise RestoreError(f"backup contains unsafe path: {name}") + if not path.parts or path.parts[0] not in {"wiki", "raw"}: + raise RestoreError(f"backup contains unsupported path: {name}") + return path + + +def inspect_backup( + link_root: Path, + backup: str | Path, + *, + include_raw: bool = False, +) -> dict[str, Any]: + """Inspect a backup archive and return the safe restore plan.""" + root = link_root.expanduser().resolve() + backup_path = _resolve_backup_path(root, backup) + if not backup_path.exists() or not backup_path.is_file(): + raise FileNotFoundError(f"backup archive not found: {backup_path}") + + restore_roots: set[str] = set() + skipped_roots: set[str] = set() + file_count = 0 + skipped_count = 0 + with tarfile.open(backup_path, "r:gz") as tar: + for member in tar.getmembers(): + safe_path = _safe_member_path(member.name) + root_name = safe_path.parts[0] + if not (member.isfile() or member.isdir()): + raise RestoreError(f"backup contains unsupported member type: {member.name}") + if root_name == "raw" and not include_raw: + skipped_roots.add("raw") + if member.isfile(): + skipped_count += 1 + continue + restore_roots.add(root_name) + if member.isfile(): + file_count += 1 + return { + "backup": str(backup_path), + "name": backup_path.name, + "include_raw": include_raw, + "restore_roots": sorted(restore_roots), + "skipped_roots": sorted(skipped_roots), + "file_count": file_count, + "skipped_file_count": skipped_count, + } + + +def restore_backup( + link_root: Path, + backup: str | Path, + *, + include_raw: bool = False, + confirm: bool = False, + safety_backup: bool = True, +) -> dict[str, Any]: + """Restore wiki/ and optionally raw/ from a local Link backup archive.""" + root = link_root.expanduser().resolve() + plan = inspect_backup(root, backup, include_raw=include_raw) + if not confirm: + return { + "restored": False, + "confirmation_required": True, + **plan, + "message": "Restore preview only. Pass --confirm to replace local files.", + } + if not plan["restore_roots"]: + raise RestoreError("backup has no restorable files") + + backup_path = Path(str(plan["backup"])) + safety: dict[str, Any] | None = None + if safety_backup and (root / "wiki").exists(): + safety = create_backup( + root, + label="pre-restore", + include_raw=include_raw and (root / "raw").exists(), + ) + + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".link-restore-", dir=root) as tmp_name: + staging = Path(tmp_name) + with tarfile.open(backup_path, "r:gz") as tar: + for member in tar.getmembers(): + safe_path = _safe_member_path(member.name) + root_name = safe_path.parts[0] + if root_name == "raw" and not include_raw: + continue + if member.isdir(): + (staging / safe_path).mkdir(parents=True, exist_ok=True) + continue + if not member.isfile(): + raise RestoreError(f"backup contains unsupported member type: {member.name}") + target_path = staging / safe_path + target_path.parent.mkdir(parents=True, exist_ok=True) + source = tar.extractfile(member) + if source is None: + raise RestoreError(f"backup member could not be read: {member.name}") + with source, target_path.open("wb") as output: + shutil.copyfileobj(source, output) + + for root_name in plan["restore_roots"]: + restored_root = staging / root_name + destination = root / root_name + if not restored_root.exists(): + continue + if destination.exists(): + if destination.is_dir(): + shutil.rmtree(destination) + else: + destination.unlink() + shutil.move(str(restored_root), str(destination)) + + return { + "restored": True, + "confirmation_required": False, + **plan, + "safety_backup": safety, + } diff --git a/mcp_package/link_core/cli_admin.py b/mcp_package/link_core/cli_admin.py index 702b169..3d598f7 100644 --- a/mcp_package/link_core/cli_admin.py +++ b/mcp_package/link_core/cli_admin.py @@ -51,7 +51,7 @@ def render_migrate_text(payload: Mapping[str, object], *, wiki_dir: object) -> t lines.append("Result: current") return 0, "\n".join(lines) lines.append(f"Result: failed ({payload['error']})") - return 1, "\n".join(lines) + return 0, "\n".join(lines) def render_status_text(payload: Mapping[str, object], *, wiki_dir: object, version: str) -> tuple[int, str]: @@ -147,6 +147,32 @@ def render_backup_created_text(payload: Mapping[str, object], *, include_raw: bo return 0, "\n".join(lines) +def render_backup_restore_text(payload: Mapping[str, object], *, target: object = ".") -> tuple[int, str]: + roots = ", ".join(str(item) for item in payload.get("restore_roots", [])) or "none" + skipped = ", ".join(str(item) for item in payload.get("skipped_roots", [])) + lines = [ + f"Link backup restore: {payload.get('name')}", + f"Archive: {payload.get('backup')}", + f"Will restore: {roots}", + f"Files: {payload.get('file_count', 0)}", + ] + if skipped: + lines.append(f"Skipped: {skipped} ({payload.get('skipped_file_count', 0)} files)") + if payload.get("restored"): + safety = payload.get("safety_backup") + if isinstance(safety, Mapping): + lines.append(f"Safety backup: {safety.get('path')}") + lines.append("Result: restored") + return 0, "\n".join(lines) + lines.extend([ + "Result: preview only", + "", + "Next:", + f" {_link_command(str(target), 'restore-backup', str(payload.get('name') or payload.get('backup')), '--confirm')}", + ]) + return 0, "\n".join(lines) + + def render_rebuild_backlinks_text(*, out_path: object, page_count: int, edge_count: int) -> tuple[int, str]: return 0, "\n".join([ f"Rebuilt {out_path}", diff --git a/mcp_package/link_core/cli_parser.py b/mcp_package/link_core/cli_parser.py index aed7216..f34a207 100644 --- a/mcp_package/link_core/cli_parser.py +++ b/mcp_package/link_core/cli_parser.py @@ -71,6 +71,14 @@ def build_cli_parser(default_demo_dir: str = DEFAULT_DEMO_DIR) -> argparse.Argum backup_cmd.add_argument("--list", action="store_true", dest="list_only", help="list recent backups instead of creating one") backup_cmd.add_argument("--json", action="store_true", help="print machine-readable backup status") + restore_backup_cmd = sub.add_parser("restore-backup", help="preview or restore a local Link backup archive") + restore_backup_cmd.add_argument("backup", help="backup filename from .link-backups/ or path to a .tar.gz archive") + restore_backup_cmd.add_argument("target", nargs="?", default=".") + restore_backup_cmd.add_argument("--include-raw", action="store_true", help="also restore raw/ if the archive contains it") + restore_backup_cmd.add_argument("--confirm", action="store_true", help="required to replace local files") + restore_backup_cmd.add_argument("--no-safety-backup", action="store_true", help="skip creating a pre-restore safety backup") + restore_backup_cmd.add_argument("--json", action="store_true", help="print machine-readable restore status") + compliance_cmd = sub.add_parser("compliance-export", help="export a redacted audit packet for security or team review") compliance_cmd.add_argument("target", nargs="?", default=".") compliance_cmd.add_argument("--output", default=None, help="write JSON to this file instead of stdout") @@ -358,6 +366,15 @@ def dispatch_cli_command(args: Any, handlers: Mapping[str, CliHandler]) -> int: list_only=args.list_only, json_output=args.json, ) + if command == "restore-backup": + return handlers["restore-backup"]( + Path(args.target), + args.backup, + include_raw=args.include_raw, + confirm=args.confirm, + safety_backup=not args.no_safety_backup, + json_output=args.json, + ) if command == "compliance-export": return handlers["compliance-export"]( Path(args.target), diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 3d119de..5afd355 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -45,6 +45,7 @@ "recall", "redact-capture", "remember", + "restore-backup", "restore-memory", "review-memory", "serve", diff --git a/tests/test_backup_core.py b/tests/test_backup_core.py index 976fb41..c00513f 100644 --- a/tests/test_backup_core.py +++ b/tests/test_backup_core.py @@ -10,7 +10,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "mcp_package")) -from link_core.backup import BackupError, create_backup, list_backups +from link_core.backup import BackupError, RestoreError, create_backup, list_backups, restore_backup class BackupCoreTests(unittest.TestCase): @@ -103,6 +103,53 @@ def test_backup_skips_symlinks(self): names = set(tar.getnames()) self.assertNotIn("wiki/concepts/outside-link.md", names) + def test_restore_backup_previews_then_restores_wiki(self): + root = self.make_root() + created = create_backup(root, label="restore test") + (root / "wiki/index.md").write_text("# Broken\n", encoding="utf-8") + + preview = restore_backup(root, created["name"]) + + self.assertFalse(preview["restored"]) + self.assertTrue(preview["confirmation_required"]) + self.assertEqual(preview["restore_roots"], ["wiki"]) + self.assertEqual((root / "wiki/index.md").read_text(encoding="utf-8"), "# Broken\n") + + restored = restore_backup(root, created["name"], confirm=True) + + self.assertTrue(restored["restored"]) + self.assertFalse(restored["confirmation_required"]) + self.assertEqual((root / "wiki/index.md").read_text(encoding="utf-8"), "# Index\n") + self.assertIn("safety_backup", restored) + + def test_restore_backup_skips_raw_unless_requested(self): + root = self.make_root() + created = create_backup(root, label="with raw", include_raw=True) + (root / "raw/secret-session.md").write_text("changed\n", encoding="utf-8") + + restored = restore_backup(root, created["name"], confirm=True, safety_backup=False) + + self.assertTrue(restored["restored"]) + self.assertEqual(restored["skipped_roots"], ["raw"]) + self.assertEqual((root / "raw/secret-session.md").read_text(encoding="utf-8"), "changed\n") + + restored_with_raw = restore_backup(root, created["name"], include_raw=True, confirm=True, safety_backup=False) + + self.assertTrue(restored_with_raw["restored"]) + self.assertEqual((root / "raw/secret-session.md").read_text(encoding="utf-8"), "api key: test-secret\n") + + def test_restore_backup_rejects_unsafe_tar_paths(self): + root = self.make_root() + archive = root / ".link-backups" / "bad.tar.gz" + archive.parent.mkdir() + payload = root / "payload.txt" + payload.write_text("bad\n", encoding="utf-8") + with tarfile.open(archive, "w:gz") as tar: + tar.add(payload, arcname="../outside.txt") + + with self.assertRaisesRegex(RestoreError, "unsafe path"): + restore_backup(root, archive) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli_parser_core.py b/tests/test_cli_parser_core.py index c9be13b..1480c22 100644 --- a/tests/test_cli_parser_core.py +++ b/tests/test_cli_parser_core.py @@ -198,6 +198,27 @@ def test_compliance_export_command_options(self): self.assertEqual(args.limit, 25) self.assertTrue(args.json) + def test_restore_backup_command_options(self): + parser = build_cli_parser() + + args = parser.parse_args([ + "restore-backup", + "backup.tar.gz", + "/tmp/link", + "--include-raw", + "--confirm", + "--no-safety-backup", + "--json", + ]) + + self.assertEqual(args.command, "restore-backup") + self.assertEqual(args.backup, "backup.tar.gz") + self.assertEqual(args.target, "/tmp/link") + self.assertTrue(args.include_raw) + self.assertTrue(args.confirm) + self.assertTrue(args.no_safety_backup) + self.assertTrue(args.json) + def test_team_sync_command_options(self): parser = build_cli_parser() From 925eec1c955db82c494b9168c215c0c3666f9e5e Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sat, 30 May 2026 22:49:55 -0600 Subject: [PATCH 29/35] Prefer lnk CLI command --- README.md | 63 +++---- docs/cli.html | 204 +++++++++++----------- docs/concepts.html | 10 +- docs/getting-started.html | 48 ++--- docs/index.html | 14 +- docs/mcp.html | 24 +-- docs/memory-contract.html | 4 +- docs/obsidian.html | 16 +- docs/scale.html | 12 +- docs/security.html | 18 +- docs/team-security.html | 46 ++--- docs/troubleshooting.html | 32 ++-- docs/ui.html | 4 +- integrations/README.md | 2 +- integrations/_shared/instructions.ps1 | 4 +- integrations/_shared/instructions.sh | 4 +- integrations/_shared/link-instructions.md | 18 +- integrations/_shared/scaffold.ps1 | 19 +- integrations/_shared/scaffold.sh | 26 +-- mcp_package/link_core/benchmark.py | 2 +- mcp_package/link_core/ingest.py | 54 +++--- mcp_package/link_core/mcp_connect.py | 14 +- mcp_package/link_core/mcp_verify.py | 13 ++ mcp_package/link_core/memory_log.py | 4 +- mcp_package/link_core/memory_wins.py | 10 +- mcp_package/link_core/obsidian.py | 4 +- mcp_package/link_core/share.py | 2 +- mcp_package/link_core/snapshot.py | 4 +- mcp_package/link_core/team_sync.py | 10 +- mcp_package/link_core/web_health.py | 2 +- mcp_package/link_core/web_propose.py | 2 +- mcp_package/link_core/wiki.py | 2 +- mcp_package/link_mcp/server.py | 2 +- packaging/homebrew/Formula/link.rb | 16 +- packaging/homebrew/README.md | 4 +- scripts/check_tool_contract.py | 2 + tests/test_benchmark_core.py | 2 +- tests/test_cli_admin_core.py | 4 +- tests/test_cli_memory_core.py | 4 +- tests/test_cli_query_core.py | 8 +- tests/test_cli_runtime_core.py | 38 ++-- tests/test_ingest_core.py | 10 +- tests/test_installers.py | 12 +- tests/test_link_cli.py | 20 +-- tests/test_mcp_connect_core.py | 4 +- tests/test_mcp_contract.py | 6 +- tests/test_operations_core.py | 2 +- tests/test_prompts_core.py | 6 +- tests/test_serve.py | 6 +- tests/test_share_core.py | 2 +- tests/test_tool_contract.py | 2 +- tests/test_web_health_core.py | 10 +- tests/test_web_ingest_core.py | 16 +- tests/test_web_memory_core.py | 14 +- tests/test_web_memory_pages_core.py | 16 +- tests/test_web_prompts_core.py | 16 +- 56 files changed, 475 insertions(+), 438 deletions(-) diff --git a/README.md b/README.md index 0c1a025..6431b1a 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,13 @@ macOS with Homebrew: ```bash brew install gowtham0992/link/link -link try -link serve link-demo +lnk try +lnk serve link-demo ``` +The installed command is `lnk` because `link` is already a POSIX/macOS system +utility. From a source checkout, use `python3 link.py ...` instead. + Windows or source checkout: ```powershell @@ -97,7 +100,7 @@ python3 link.py next link-demo python3 link.py serve link-demo ``` -Use `link try` for the shortest Homebrew proof loop. It creates the demo, +Use `lnk try` for the shortest Homebrew proof loop. It creates the demo, checks readiness, runs a compact query/brief proof, and prints the agent prompts and viewer command. From source, use `python3 link.py try`. @@ -116,15 +119,15 @@ The web viewer is for local use only. It binds to `127.0.0.1`, has no user accounts or authentication, and should not be exposed to the internet unless you add your own auth layer. -For the shortest guided proof path, run `link welcome link-demo`. +For the shortest guided proof path, run `lnk welcome link-demo`. Try the value loop: ```bash -link query "why does Link help agents?" link-demo --budget small -link brief "working on agent memory" link-demo -link benchmark "agent memory" link-demo -link health link-demo +lnk query "why does Link help agents?" link-demo --budget small +lnk brief "working on agent memory" link-demo +lnk benchmark "agent memory" link-demo +lnk health link-demo ``` The `/health` page mirrors the readiness loop in the browser: validation state, @@ -168,7 +171,7 @@ limits are. Pick the surface that matches how you work. They all read and write the same local Markdown wiki. -These surfaces are independent. `link serve` / `serve.py` is only the local web +These surfaces are independent. `lnk serve` / `serve.py` is only the local web viewer. CLI commands and MCP tools read the same `wiki/` files directly, so Claude, Codex, Kiro, Cursor, or another MCP client can use Link even when the web viewer is not running. @@ -239,10 +242,10 @@ connection helper. It previews the exact config first; add `--write` when you want Link to update the agent config file. ```bash -link connect codex ~/link -link connect codex ~/link --write -link connect kiro ~/link --write -link verify-mcp ~/link +lnk connect codex ~/link +lnk connect codex ~/link --write +lnk connect kiro ~/link --write +lnk verify-mcp ~/link ```
    @@ -279,8 +282,8 @@ Obsidian users can import an existing vault into `raw/` for agent ingest, or open `~/link/wiki` directly as a vault for editing Link pages: ```bash -link init ~/link -link import-obsidian ~/Documents/ObsidianVault ~/link +lnk init ~/link +lnk import-obsidian ~/Documents/ObsidianVault ~/link ``` See the [Obsidian guide](https://gowtham0992.github.io/link/obsidian.html) for @@ -307,7 +310,7 @@ The storage model is plain and inspectable: | `wiki/` | Source-backed pages, concepts, entities, explorations, comparisons, and memories. | | MCP tools | Compact packets agents can use without dumping the whole wiki into context. | -If a raw file was already ingested and later edited, `link ingest-status` marks it +If a raw file was already ingested and later edited, `lnk ingest-status` marks it as stale and tells your agent to refresh the existing source page instead of creating a duplicate. @@ -346,42 +349,42 @@ Use `visibility` to separate where a memory applies from who should see it: `private` stays personal, `project` is intended for a project workspace, and `team` means the user explicitly approved sharing it with a team. -For team handoff or security review, `link compliance-export --output audit.json` +For team handoff or security review, `lnk compliance-export --output audit.json` writes a redacted JSON packet with readiness, validation, memory review status, operation markers, and recent audit log entries. Raw source contents and memory bodies are not included. -For day-to-day auditability, `link memory-log ~/link` shows what Link recently +For day-to-day auditability, `lnk memory-log ~/link` shows what Link recently remembered, updated, reviewed, archived, restored, forgot, or accepted from raw captures. -For recovery, `link backup ~/link` creates a local archive and `link +For recovery, `lnk backup ~/link` creates a local archive and `lnk restore-backup ~/link` previews what would be restored. Passing `--confirm` replaces local files after creating a safety backup when possible; `raw/` is still excluded unless `--include-raw` is explicit. -For local proof of value, `link wins ~/link` shows reusable memories, reviewed +For local proof of value, `lnk wins ~/link` shows reusable memories, reviewed memory, provenance, project continuity, freshness guardrails, and copyable prompts without tracking user behavior. -For Git-backed team memory, `link team-sync ~/link` checks whether the workspace +For Git-backed team memory, `lnk team-sync ~/link` checks whether the workspace is ready to share reviewed `wiki/` pages while keeping `raw/`, caches, backups, and local MCP Python markers private by default. It also blocks "ready" status when the memory inbox is not clear or active `visibility: private` memories would be included by a broad `git add wiki`. ```bash -link team-sync ~/link --remote git@example.com:team/link-memory.git +lnk team-sync ~/link --remote git@example.com:team/link-memory.git ``` -For a teammate, reviewer, or another agent, `link share` resolves a page, +For a teammate, reviewer, or another agent, `lnk share` resolves a page, memory, title, alias, or search phrase into a local viewer URL: ```bash -link share "Prefer local memory" ~/link +lnk share "Prefer local memory" ~/link ``` -For a static, read-only review packet, `link snapshot` exports rendered wiki +For a static, read-only review packet, `lnk snapshot` exports rendered wiki HTML without `raw/`, captures, operation markers, live MCP state, or memory pages by default. `--include-memories` exports only non-private memories; use `--include-private-memories` only for a personal archive or an explicitly @@ -389,9 +392,9 @@ approved review. It blocks export if wiki pages contain secret-looking values unless you explicitly override it. ```bash -link snapshot ~/link --output link-snapshot -link snapshot ~/link --output link-snapshot --include-memories --force -link snapshot ~/link --output personal-snapshot --include-memories --include-private-memories --force +lnk snapshot ~/link --output link-snapshot +lnk snapshot ~/link --output link-snapshot --include-memories --force +lnk snapshot ~/link --output personal-snapshot --include-memories --include-private-memories --force ``` ## Agent Contract @@ -417,10 +420,10 @@ Link itself is local-first: - No hosted backend. - No external API calls from `serve.py` or `link-mcp`. - Raw sources and generated wiki pages are ignored by git by default. -- `link backup` excludes `raw/` unless you explicitly pass `--include-raw`. +- `lnk backup` excludes `raw/` unless you explicitly pass `--include-raw`. - Secret-looking API keys, provider tokens, JWTs, registry credentials, and private key blocks are detected in raw sources, captures, and release hygiene - checks. `link validate` and `link doctor` also fail if secret-looking values + checks. `lnk validate` and `lnk doctor` also fail if secret-looking values are found inside wiki pages before they can be served through the local UI or returned through MCP context. - The local web server binds to `127.0.0.1` and is not meant to be exposed to diff --git a/docs/cli.html b/docs/cli.html index 16c38d0..5576cf6 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -38,7 +38,7 @@
    Command reference

    Local commands for daily memory work.

    -

    Install the CLI with brew install gowtham0992/link/link on macOS, or use link <command> after a global agent installer run. From a source checkout, use python3 link.py <command> on macOS/Linux or py link.py <command> on Windows.

    +

    Install the CLI with brew install gowtham0992/link/link on macOS, or use lnk <command> after a global agent installer run. Link uses lnk because link is already a POSIX/macOS system utility. From a source checkout, use python3 link.py <command> on macOS/Linux or py link.py <command> on Windows.

    @@ -59,123 +59,123 @@

    CLI Tour

    Animated Link CLI walkthrough
    Status, query, brief, and benchmark are the core terminal loop.
    -

    The CLI is independent of the web viewer. Use link query, link brief, link health, and maintenance commands even when link serve is not running.

    -

    Use link try when you want the shortest proof loop: demo creation, readiness, query proof, brief proof, viewer command, and first agent prompts in one command.

    +

    The CLI is independent of the web viewer. Use lnk query, lnk brief, lnk health, and maintenance commands even when lnk serve is not running.

    +

    Use lnk try when you want the shortest proof loop: demo creation, readiness, query proof, brief proof, viewer command, and first agent prompts in one command.

    Daily Loop

    -
    link serve
    -link welcome
    -link next
    -link health
    -link ingest-status
    -link remember "User prefers feature branches for Link work." --type preference --scope project --project link --visibility project --review-after 2026-08-01
    -link remember "Temporary launch branch is release/one-off." --type project --project link --expires-at 2026-09-01
    -link share "Prefer local memory"
    -link snapshot ~/link --output link-snapshot
    -link brief "working on Link release" --project link
    -link query "what should I know before changing the MCP tools?" --budget small --project link
    -link validate
    +
    lnk serve
    +lnk welcome
    +lnk next
    +lnk health
    +lnk ingest-status
    +lnk remember "User prefers feature branches for Link work." --type preference --scope project --project link --visibility project --review-after 2026-08-01
    +lnk remember "Temporary launch branch is release/one-off." --type project --project link --expires-at 2026-09-01
    +lnk share "Prefer local memory"
    +lnk snapshot ~/link --output link-snapshot
    +lnk brief "working on Link release" --project link
    +lnk query "what should I know before changing the MCP tools?" --budget small --project link
    +lnk validate

    Memory Commands

    -

    link remember

    Save a local agent memory. Strong duplicates and likely conflicts are refused unless explicitly allowed.

    -

    link recall

    Search local memories with recall readiness and project-aware filtering.

    -

    link explain-memory

    Show provenance, lifecycle, graph links, review issues, and recall readiness.

    -

    link update-memory

    Merge new text into an existing memory and reset review state.

    -

    link archive-memory

    Reversibly hide a stale or wrong memory from default recall.

    -

    link forget-memory

    Permanently delete a memory after explicit confirmation.

    +

    lnk remember

    Save a local agent memory. Strong duplicates and likely conflicts are refused unless explicitly allowed.

    +

    lnk recall

    Search local memories with recall readiness and project-aware filtering.

    +

    lnk explain-memory

    Show provenance, lifecycle, graph links, review issues, and recall readiness.

    +

    lnk update-memory

    Merge new text into an existing memory and reset review state.

    +

    lnk archive-memory

    Reversibly hide a stale or wrong memory from default recall.

    +

    lnk forget-memory

    Permanently delete a memory after explicit confirmation.

    Capture Workflow

    Use captures for longer chat notes or session summaries that should be reviewed before becoming durable memory.

    -
    link capture-session session-notes.md --project link
    -link capture-inbox --project link
    -link accept-capture raw/memory-captures/<capture>.md --index 1
    -link redact-capture raw/memory-captures/<capture>.md
    -link delete-capture raw/memory-captures/<capture>.md --confirm
    +
    lnk capture-session session-notes.md --project link
    +lnk capture-inbox --project link
    +lnk accept-capture raw/memory-captures/<capture>.md --index 1
    +lnk redact-capture raw/memory-captures/<capture>.md
    +lnk delete-capture raw/memory-captures/<capture>.md --confirm

    Maintenance

    -
    link backup
    -link doctor --fix
    -link health
    -link status --validate
    -link memory-audit
    -link compliance-export --output link-audit.json
    -link team-sync ~/link
    -link snapshot ~/link --output link-snapshot
    -link operations
    -link benchmark "agent memory"
    -link rebuild-index
    -link rebuild-backlinks
    -link validate
    -link verify-mcp
    -

    Use link backup before broad repair work. Use link benchmark when a wiki starts to feel slow. link status --validate and link benchmark both show persistent-cache reuse so you can tell whether Link is rereading every page or reusing unchanged records.

    -

    Use link team-sync before sharing Link through Git. It is read-only: it checks Git state, verifies that raw/ is protected, checks whether review or visibility: private memories would make sharing unsafe, and prints paste-safe setup/sync commands without pushing private source material for you.

    -

    Use link snapshot when you need a static, read-only HTML export for a teammate, maintainer, security reviewer, or demo. It excludes raw/, captures, local operation state, and memory pages by default. With --include-memories, it still excludes visibility: private memories unless --include-private-memories is explicitly passed.

    -

    Use link connect <agent> when an agent already has Link instructions but still needs MCP wiring. It previews the config before writing.

    -
    link connect codex ~/link
    -link connect codex ~/link --write
    -link connect kiro ~/link --write
    -link verify-mcp ~/link
    -

    From a source checkout, use the synthetic large-wiki smoke when you want local scale evidence without touching your real wiki. The script prints the exact link serve command and graph URL for the generated fixture.

    +
    lnk backup
    +lnk doctor --fix
    +lnk health
    +lnk status --validate
    +lnk memory-audit
    +lnk compliance-export --output link-audit.json
    +lnk team-sync ~/link
    +lnk snapshot ~/link --output link-snapshot
    +lnk operations
    +lnk benchmark "agent memory"
    +lnk rebuild-index
    +lnk rebuild-backlinks
    +lnk validate
    +lnk verify-mcp
    +

    Use lnk backup before broad repair work. Use lnk benchmark when a wiki starts to feel slow. lnk status --validate and lnk benchmark both show persistent-cache reuse so you can tell whether Link is rereading every page or reusing unchanged records.

    +

    Use lnk team-sync before sharing Link through Git. It is read-only: it checks Git state, verifies that raw/ is protected, checks whether review or visibility: private memories would make sharing unsafe, and prints paste-safe setup/sync commands without pushing private source material for you.

    +

    Use lnk snapshot when you need a static, read-only HTML export for a teammate, maintainer, security reviewer, or demo. It excludes raw/, captures, local operation state, and memory pages by default. With --include-memories, it still excludes visibility: private memories unless --include-private-memories is explicitly passed.

    +

    Use lnk connect <agent> when an agent already has Link instructions but still needs MCP wiring. It previews the config before writing.

    +
    lnk connect codex ~/link
    +lnk connect codex ~/link --write
    +lnk connect kiro ~/link --write
    +lnk verify-mcp ~/link
    +

    From a source checkout, use the synthetic large-wiki smoke when you want local scale evidence without touching your real wiki. The script prints the exact lnk serve command and graph URL for the generated fixture.

    python3 scripts/smoke_large_wiki.py --pages 10000

    All Commands

    -
    link --version
    -link version
    -link init [dir]
    -link serve [dir] [--port 3000]
    -link try [dir] [--force] [--serve] [--port 3000]
    -link welcome [dir] [--project slug]
    -link prompts [dir] [--project slug]
    -link next [dir] [--project slug]
    -link health [dir] [--json]
    -link status [--validate]
    -link operations [--limit 20]
    -link backup [--label name] [--include-raw]
    -link compliance-export [dir] [--output audit.json] [--project slug]
    -link team-sync [dir] [--remote git-url]
    -link share <page-or-memory> [dir] [--port 3000]
    -link snapshot [dir] [--output link-snapshot] [--include-memories] [--include-private-memories] [--force]
    -link ingest-status
    -link import-obsidian <vault> [dir] [--dry-run] [--overwrite]
    -link remember "text" [--project slug] [--visibility private|project|team] [--review-after YYYY-MM-DD] [--expires-at YYYY-MM-DD]
    -link propose-memories <file-or-text> [--project slug]
    -link capture-session <file-or-text> [--project slug]
    -link capture-inbox [--project slug]
    -link accept-capture <capture> [--index N] [--visibility private|project|team]
    -link redact-capture <capture>
    -link delete-capture <capture> --confirm
    -link query "task" [--budget small|medium|large] [--project slug]
    -link graph-summary ["topic"] [--limit 40] [--depth 1]
    -link benchmark ["query"] [--budget small|medium|large] [--project slug]
    -link brief "task" [--project slug]
    -link memory-audit [--project slug]
    -link recall "query" [--project slug]
    -link profile [--project slug]
    -link wins [--project slug]
    -link memory-inbox [--project slug]
    -link memory-log [--limit N]
    -link review-memory <name>
    -link explain-memory <name>
    -link update-memory <name> "text" [--project slug]
    -link set-memory-visibility <name> private|project|team
    -link archive-memory <name>
    -link restore-memory <name>
    -link forget-memory <name> --confirm
    -link doctor
    -link doctor --fix
    -link migrate
    -link validate [--strict]
    -link rebuild-index
    -link rebuild-backlinks
    -link verify-mcp [--json]
    -link connect <agent> [dir] [--write] [--config path] [--python python]
    -link backup [--list] [--include-raw]
    -link restore-backup <backup.tar.gz> [--include-raw] --confirm
    +        
    lnk --version
    +lnk version
    +lnk init [dir]
    +lnk serve [dir] [--port 3000]
    +lnk try [dir] [--force] [--serve] [--port 3000]
    +lnk welcome [dir] [--project slug]
    +lnk prompts [dir] [--project slug]
    +lnk next [dir] [--project slug]
    +lnk health [dir] [--json]
    +lnk status [--validate]
    +lnk operations [--limit 20]
    +lnk backup [--label name] [--include-raw]
    +lnk compliance-export [dir] [--output audit.json] [--project slug]
    +lnk team-sync [dir] [--remote git-url]
    +lnk share <page-or-memory> [dir] [--port 3000]
    +lnk snapshot [dir] [--output link-snapshot] [--include-memories] [--include-private-memories] [--force]
    +lnk ingest-status
    +lnk import-obsidian <vault> [dir] [--dry-run] [--overwrite]
    +lnk remember "text" [--project slug] [--visibility private|project|team] [--review-after YYYY-MM-DD] [--expires-at YYYY-MM-DD]
    +lnk propose-memories <file-or-text> [--project slug]
    +lnk capture-session <file-or-text> [--project slug]
    +lnk capture-inbox [--project slug]
    +lnk accept-capture <capture> [--index N] [--visibility private|project|team]
    +lnk redact-capture <capture>
    +lnk delete-capture <capture> --confirm
    +lnk query "task" [--budget small|medium|large] [--project slug]
    +lnk graph-summary ["topic"] [--limit 40] [--depth 1]
    +lnk benchmark ["query"] [--budget small|medium|large] [--project slug]
    +lnk brief "task" [--project slug]
    +lnk memory-audit [--project slug]
    +lnk recall "query" [--project slug]
    +lnk profile [--project slug]
    +lnk wins [--project slug]
    +lnk memory-inbox [--project slug]
    +lnk memory-log [--limit N]
    +lnk review-memory <name>
    +lnk explain-memory <name>
    +lnk update-memory <name> "text" [--project slug]
    +lnk set-memory-visibility <name> private|project|team
    +lnk archive-memory <name>
    +lnk restore-memory <name>
    +lnk forget-memory <name> --confirm
    +lnk doctor
    +lnk doctor --fix
    +lnk migrate
    +lnk validate [--strict]
    +lnk rebuild-index
    +lnk rebuild-backlinks
    +lnk verify-mcp [--json]
    +lnk connect <agent> [dir] [--write] [--config path] [--python python]
    +lnk backup [--list] [--include-raw]
    +lnk restore-backup <backup.tar.gz> [--include-raw] --confirm
     python3 link.py demo
     python3 link.py query-link "task" [dir]
    -

    query-link is kept as an internal/backward-compatible alias. Prefer link query in user-facing docs.

    +

    query-link is kept as an internal/backward-compatible alias. Prefer lnk query in user-facing docs.

    diff --git a/docs/concepts.html b/docs/concepts.html index f388dce..65c4a3d 100644 --- a/docs/concepts.html +++ b/docs/concepts.html @@ -113,11 +113,11 @@

    Graph Context

    Scale Model

    Link uses local caching, token indexes, bounded page APIs, bounded graph summaries, and optional in-memory SQLite FTS5 for fast search. If SQLite is unavailable, Link falls back to the token index.

    -
    link benchmark "agent memory"
    -link graph-summary "local memory" --limit 40 --depth 1
    -link validate
    -link rebuild-backlinks
    -

    Use link benchmark when a wiki starts to feel slow. It reports cache time, persistent-cache reuse, search, query, graph payloads, backend, and readiness recommendations.

    +
    lnk benchmark "agent memory"
    +lnk graph-summary "local memory" --limit 40 --depth 1
    +lnk validate
    +lnk rebuild-backlinks
    +

    Use lnk benchmark when a wiki starts to feel slow. It reports cache time, persistent-cache reuse, search, query, graph payloads, backend, and readiness recommendations.

    diff --git a/docs/getting-started.html b/docs/getting-started.html index 436ac37..58f7d45 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -58,8 +58,8 @@

    1. Run The Demo

    The demo is the fastest proof of value. It already has raw sources, wiki pages, memories, backlinks, and graph data.

    macOS with Homebrew:

    brew install gowtham0992/link/link
    -link try
    -link serve link-demo
    +lnk try +lnk serve link-demo

    The Homebrew formula is maintained in the public gowtham0992/homebrew-link tap.

    Or from source:

    git clone https://github.com/gowtham0992/link.git
    @@ -73,21 +73,21 @@ 

    1. Run The Demo

    py link.py demo py link.py next link-demo py link.py serve link-demo
    -

    Use link try for the shortest Homebrew proof loop. It creates the demo, checks readiness, runs a compact query and brief, and prints the viewer command plus the first agent prompts. From source, use python3 link.py try or py link.py try.

    +

    Use lnk try for the shortest Homebrew proof loop. It creates the demo, checks readiness, runs a compact query and brief, and prints the viewer command plus the first agent prompts. From source, use python3 link.py try or py link.py try.

    Judge the generated demo The repo's root wiki/ is only a scaffold for local development and personal testing. Generated content in wiki/, raw/, and link-demo/ is ignored by git so private memory is not published by accident.
    Viewer is optional - link serve is only for browsing Link in a local web UI. CLI commands and MCP-enabled agents work without it because they read the same local wiki/ files directly. + lnk serve is only for browsing Link in a local web UI. CLI commands and MCP-enabled agents work without it because they read the same local wiki/ files directly.
    -

    The demo includes one pending memory intentionally, so the review inbox and explain-memory workflow are visible. Run link review-memory prefer-local-personal-memory link-demo if you want memory audit to be fully clear.

    +

    The demo includes one pending memory intentionally, so the review inbox and explain-memory workflow are visible. Run lnk review-memory prefer-local-personal-memory link-demo if you want memory audit to be fully clear.

    Open http://127.0.0.1:3000, then inspect /brief, /memory, /ingest, /graph, and /health. Open more for prompts, proposal review, audit, captures, profile, log, and all pages. Link accepts localhost too, but the numeric loopback address avoids slow IPv6 fallback in some Safari setups.

    -
    link query "why does Link help agents?" link-demo --budget small
    -link brief "working on agent memory" link-demo
    -link benchmark "agent memory" link-demo
    -link health link-demo
    +
    lnk query "why does Link help agents?" link-demo --budget small
    +lnk brief "working on agent memory" link-demo
    +lnk benchmark "agent memory" link-demo
    +lnk health link-demo

    2. Install Link For Your Agent

    From the cloned checkout, run the installer for the agent you use. Re-running the same installer updates code and instructions without replacing existing wiki data.

    @@ -124,14 +124,14 @@

    3. Add One Source

    Raw notes stay local. The agent turns them into source-cited wiki pages. EOF

    Check pending work:

    -
    link ingest-status
    +
    lnk ingest-status
    Safety gate Link blocks normal ingest guidance when raw files contain secret-looking values or cannot be read safely. Redact or fix those local files first.
    Existing Link data - If you already have files in ~/link/raw/, link ingest-status may point to a different pending file first. If first-memory.md was already ingested and you overwrite it, Link marks that raw file as stale and asks the agent to refresh the existing source page. + If you already have files in ~/link/raw/, lnk ingest-status may point to a different pending file first. If first-memory.md was already ingested and you overwrite it, Link marks that raw file as stale and asks the agent to refresh the existing source page.

    4. Save One Direct Memory

    @@ -140,11 +140,11 @@

    4. Save One Direct Memory

    brief me from Link before we continue what does Link remember about local personal memory?

    Or use the CLI:

    -
    link remember "I am testing Link as local personal memory for agents." --type preference --scope user --tags onboarding
    -link brief "local personal memory"
    -link recall "local personal memory"
    -link profile
    -link memory-audit
    +
    lnk remember "I am testing Link as local personal memory for agents." --type preference --scope user --tags onboarding
    +lnk brief "local personal memory"
    +lnk recall "local personal memory"
    +lnk profile
    +lnk memory-audit

    5. Ask The Agent To Ingest

    In your agent chat, ask:

    @@ -153,14 +153,14 @@

    5. Ask The Agent To Ingest

    Return to /ingest after the agent finishes. Link shows which raw files are represented and gives follow-up prompts for proposals or retrieval checks.

    6. Verify The Loop

    -
    link doctor --fix
    -link health
    -link ingest-status
    -link validate
    -link memory-audit
    -link operations
    -link verify-mcp
    -

    link verify-mcp should report Result: ready. Then ask your MCP-enabled agent:

    +
    lnk doctor --fix
    +lnk health
    +lnk ingest-status
    +lnk validate
    +lnk memory-audit
    +lnk operations
    +lnk verify-mcp
    +

    lnk verify-mcp should report Result: ready. Then ask your MCP-enabled agent:

    query Link for first Link memory

    If the answer comes from Link, local agent memory is working.

    diff --git a/docs/index.html b/docs/index.html index 64b0cc2..0d45351 100644 --- a/docs/index.html +++ b/docs/index.html @@ -130,9 +130,9 @@

    Run a finished memory wiki locally.

    # macOS
     brew install gowtham0992/link/link
    -link demo
    -link next link-demo
    -link serve link-demo
    +lnk demo
    +lnk next link-demo
    +lnk serve link-demo
     
     # or from source
     git clone https://github.com/gowtham0992/link.git
    @@ -142,9 +142,9 @@ 

    Run a finished memory wiki locally.

    python3 link.py serve link-demo # then try -link query "why does Link help agents?" link-demo --budget small -link brief "working on agent memory" link-demo -link benchmark "agent memory" link-demo
    +lnk query "why does Link help agents?" link-demo --budget small +lnk brief "working on agent memory" link-demo +lnk benchmark "agent memory" link-demo @@ -155,7 +155,7 @@

    Review it, script it, or let an agent call it.

    The web UI, CLI, and MCP server all operate on the same local Markdown wiki. Read it like a local document, script it from a terminal, or let an agent query the same memory through MCP.

    No background web server required - link serve only starts the human web viewer. The CLI and MCP server read the same local files directly, so agents can query Link when the viewer is closed. + lnk serve only starts the human web viewer. The CLI and MCP server read the same local files directly, so agents can query Link when the viewer is closed.
    diff --git a/docs/mcp.html b/docs/mcp.html index 41f894e..cd7d637 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -83,14 +83,14 @@

    Agent Installers

    .\integrations\copilot\install.ps1 .\integrations\vscode\install.ps1 .\integrations\antigravity\install.ps1 -

    If you already have agent instructions and only need the MCP config, use link connect. It previews the target file and config snippet first; add --write for an explicit update.

    -
    link connect codex ~/link
    -link connect codex ~/link --write
    -link connect kiro ~/link --write
    -link connect claude-code ~/link --write
    -link connect cursor ~/link --write
    -link connect antigravity ~/link --write
    -link verify-mcp ~/link
    +

    If you already have agent instructions and only need the MCP config, use lnk connect. It previews the target file and config snippet first; add --write for an explicit update.

    +
    lnk connect codex ~/link
    +lnk connect codex ~/link --write
    +lnk connect kiro ~/link --write
    +lnk connect claude-code ~/link --write
    +lnk connect cursor ~/link --write
    +lnk connect antigravity ~/link --write
    +lnk verify-mcp ~/link

    MCP Only

    python3 -m pip install --upgrade link-mcp
    @@ -174,10 +174,10 @@ 

    MCP Tools

    Project-aware tools accept an optional project argument. When set, Link returns broad user/global memory plus memories for that project, while excluding memories from other explicit projects.

    Verify Setup

    -
    link verify-mcp
    -link health
    -link next
    -

    link verify-mcp --json is useful when an agent or script should read structured issues and next actions.

    +
    lnk verify-mcp
    +lnk health
    +lnk next
    +

    lnk verify-mcp --json is useful when an agent or script should read structured issues and next actions.

    Natural prompts to try

    is Link ready?

    diff --git a/docs/memory-contract.html b/docs/memory-contract.html index 23913cf..08e52f6 100644 --- a/docs/memory-contract.html +++ b/docs/memory-contract.html @@ -114,8 +114,8 @@

    Sharing Semantics

    visibilityprivate, project, teamControls sharing intent for Git sync and snapshots. -

    By default, user/global memories are private and project memories are project-visible. link team-sync blocks ready status if private memories would be included in a broad Git share. link snapshot --include-memories still excludes private memories unless --include-private-memories is explicitly passed.

    -

    Use set_memory_visibility or link set-memory-visibility only after explicit user approval when an existing memory should move between private, project, and team sharing intent.

    +

    By default, user/global memories are private and project memories are project-visible. lnk team-sync blocks ready status if private memories would be included in a broad Git share. lnk snapshot --include-memories still excludes private memories unless --include-private-memories is explicitly passed.

    +

    Use set_memory_visibility or lnk set-memory-visibility only after explicit user approval when an existing memory should move between private, project, and team sharing intent.

    Budgets

    query_link supports small, medium, and large budgets. Each packet includes why an item was selected, estimated tokens, has_more flags, and follow-up actions. This keeps agents from reading a 500-page or 10,000-page wiki just to answer one task.

    diff --git a/docs/obsidian.html b/docs/obsidian.html index dd27b80..2d5f960 100644 --- a/docs/obsidian.html +++ b/docs/obsidian.html @@ -53,10 +53,10 @@

    Open Link in Obsidian when you want a richer notes UI.

    Import An Existing Obsidian Vault

    -

    Use link import-obsidian when you already have project notes in Obsidian and want Link agents to ingest them as source-backed knowledge. Link copies Markdown notes into raw/obsidian/<vault>/, skips Obsidian plugin state, and blocks notes with secret-looking values before writing them.

    -
    link init ~/link
    -link import-obsidian ~/Documents/ObsidianVault ~/link
    -link ingest-status ~/link
    +

    Use lnk import-obsidian when you already have project notes in Obsidian and want Link agents to ingest them as source-backed knowledge. Link copies Markdown notes into raw/obsidian/<vault>/, skips Obsidian plugin state, and blocks notes with secret-looking values before writing them.

    +
    lnk init ~/link
    +lnk import-obsidian ~/Documents/ObsidianVault ~/link
    +lnk ingest-status ~/link

    The import does not silently create durable memories. It stages raw source notes, then the normal ingest and proposal workflow decides what becomes wiki knowledge or reviewed memory.

    Open The Link Wiki

    @@ -73,10 +73,10 @@

    Edit Safely

    Keep Link Indexes Current

    After manual Obsidian edits, rebuild derived indexes and validate the wiki before asking agents to rely on the new content:

    -
    link rebuild-index ~/link
    -link rebuild-backlinks ~/link
    -link validate ~/link
    -link health ~/link
    +
    lnk rebuild-index ~/link
    +lnk rebuild-backlinks ~/link
    +lnk validate ~/link
    +lnk health ~/link

    The local web viewer is still useful for Link-specific views like health, ingest, memory review, captures, and MCP-ready query context. Obsidian is an editor/viewer for the same underlying files, not a replacement for Link's validation and memory lifecycle.

    diff --git a/docs/scale.html b/docs/scale.html index e582961..c8e233b 100644 --- a/docs/scale.html +++ b/docs/scale.html @@ -74,10 +74,10 @@

    Bounded Surfaces

    Measure Locally

    -

    Use link benchmark on your real wiki. It reports cache time, persistent-cache reuse, search backend, search/query timing, graph payload shape, and recommendations.

    -
    link benchmark "agent memory"
    -link health
    -link status --validate
    +

    Use lnk benchmark on your real wiki. It reports cache time, persistent-cache reuse, search backend, search/query timing, graph payload shape, and recommendations.

    +
    lnk benchmark "agent memory"
    +lnk health
    +lnk status --validate

    From a source checkout, use a synthetic 10k-page check without touching your real wiki:

    python3 scripts/smoke_large_wiki.py --pages 10000

    The smoke script prints the generated wiki path, the local viewer command, and graph URLs so you can inspect browser behavior manually.

    @@ -86,8 +86,8 @@

    Large Wiki Habits

    • Prefer brief, query, and graph-summary over full exports.
    • Use page-type filters and search before opening full graph data.
    • -
    • Run link health after ingest or broad manual edits.
    • -
    • Run link doctor --fix only when health or validation points to a repairable issue.
    • +
    • Run lnk health after ingest or broad manual edits.
    • +
    • Run lnk doctor --fix only when health or validation points to a repairable issue.
    • Keep private raw sources out of Git; use snapshot, team-sync, and compliance-export for reviewable sharing paths.
    diff --git a/docs/security.html b/docs/security.html index 784fa09..b459a2c 100644 --- a/docs/security.html +++ b/docs/security.html @@ -67,11 +67,11 @@

    Privacy Model

    Secret Handling

    Link scans raw sources, captures, wiki pages, release files, and public artifacts for secret-looking values. It detects common API keys, provider tokens, JWTs, private key blocks, and registry credentials, warns without logging secret values, and refuses normal ingest guidance when raw safety cannot be established. Validation and doctor checks also fail if a secret-looking value is already present in a wiki page before the local UI or MCP tools can serve it as context.

    -
    link ingest-status
    -link capture-inbox
    -link redact-capture raw/memory-captures/<capture>.md
    -link validate
    -link doctor
    +        
    lnk ingest-status
    +lnk capture-inbox
    +lnk redact-capture raw/memory-captures/<capture>.md
    +lnk validate
    +lnk doctor
     python3 scripts/check_release_hygiene.py
    Rule @@ -83,10 +83,10 @@

    HTTP Boundary

    HTTP write actions require X-Link-Local-Action: true. Responses include X-Link-API-Version. Proposal analysis does not write pages.

    Backups

    -

    link backup and MCP backup_wiki write local .link-backups/ archives. Raw sources are excluded unless explicitly requested.

    -
    link backup
    -link backup --include-raw
    -link doctor --fix
    +

    lnk backup and MCP backup_wiki write local .link-backups/ archives. Raw sources are excluded unless explicitly requested.

    +
    lnk backup
    +lnk backup --include-raw
    +lnk doctor --fix

    Run a backup before broad repair work or large generated changes.

    Team Review

    diff --git a/docs/team-security.html b/docs/team-security.html index 1be9acb..b83b9ea 100644 --- a/docs/team-security.html +++ b/docs/team-security.html @@ -59,12 +59,12 @@

    Deployment Model

    Link is designed as a local personal or repo-local memory layer. Each developer runs the CLI and MCP server on their own machine. The web viewer is optional and only serves the local UI.

    No server dependency - link serve is only the human web viewer. CLI and MCP access work directly against local Markdown files when the viewer is closed. + lnk serve is only the human web viewer. CLI and MCP access work directly against local Markdown files when the viewer is closed.
    brew install gowtham0992/link/link
    -link init ~/link
    -link health ~/link
    -link connect codex ~/link
    +lnk init ~/link +lnk health ~/link +lnk connect codex ~/link

    Data Boundaries

      @@ -74,30 +74,30 @@

      Data Boundaries

    • The installed product has no telemetry, hosted backend, or outbound API calls.
    • Secret-looking values are scanned before capture, ingest, Obsidian import, and doctor checks.
    -
    link ingest-status ~/link
    -link doctor ~/link
    -link validate ~/link
    +
    lnk ingest-status ~/link
    +lnk doctor ~/link
    +lnk validate ~/link

    Memory Approval Gates

    Agents can propose memories, but durable memory should be explicit and reviewable. Link keeps memory as Markdown with type, scope, visibility, project, source, review status, optional review dates, and optional expiry dates.

    -
    link propose-memories raw/notes.md ~/link
    -link memory-inbox ~/link
    -link review-memory memory-name ~/link
    -link archive-memory memory-name ~/link --reason stale
    +
    lnk propose-memories raw/notes.md ~/link
    +lnk memory-inbox ~/link
    +lnk review-memory memory-name ~/link
    +lnk archive-memory memory-name ~/link --reason stale

    For temporary context, use expires_at. For decisions that should be re-confirmed, use review_after. For team handoff, keep personal context at visibility: private and only mark memories project or team after the user explicitly approves sharing them.

    Team Sharing Pattern

    The safest early team workflow is Git-backed sharing of reviewed wiki pages. Keep raw sources local unless the team explicitly decides to share them.

    -
    link team-sync ~/link --remote git@example.com:team/link-memory.git
    -link compliance-export ~/link --output link-audit.json
    -link backup ~/link
    -

    link team-sync is read-only. It checks Git state, raw-source protection, review readiness, and whether active visibility: private memories would be swept into a broad git add wiki. It prints paste-safe commands instead of pushing data for you.

    +
    lnk team-sync ~/link --remote git@example.com:team/link-memory.git
    +lnk compliance-export ~/link --output link-audit.json
    +lnk backup ~/link
    +

    lnk team-sync is read-only. It checks Git state, raw-source protection, review readiness, and whether active visibility: private memories would be swept into a broad git add wiki. It prints paste-safe commands instead of pushing data for you.

    Audit Packet

    -

    link compliance-export creates a redacted JSON packet for review. It includes readiness, validation status, memory review counts, operation markers, recent audit log metadata, and safe next actions. Raw source contents and memory bodies are excluded.

    -
    link compliance-export ~/link --output link-audit.json
    -link wins ~/link
    -link memory-log ~/link
    +

    lnk compliance-export creates a redacted JSON packet for review. It includes readiness, validation status, memory review counts, operation markers, recent audit log metadata, and safe next actions. Raw source contents and memory bodies are excluded.

    +
    lnk compliance-export ~/link --output link-audit.json
    +lnk wins ~/link
    +lnk memory-log ~/link

    Current Limits

      @@ -109,11 +109,11 @@

      Current Limits

      Security Review Checklist

        -
      1. Run link health ~/link and verify readiness is green.
      2. -
      3. Run link doctor ~/link and resolve secret or validation warnings.
      4. -
      5. Run link compliance-export ~/link --output link-audit.json.
      6. +
      7. Run lnk health ~/link and verify readiness is green.
      8. +
      9. Run lnk doctor ~/link and resolve secret or validation warnings.
      10. +
      11. Run lnk compliance-export ~/link --output link-audit.json.
      12. Confirm raw/, backups, caches, and local MCP Python markers are ignored by Git.
      13. -
      14. Review wiki/log.md, link memory-log ~/link, and link wins ~/link.
      15. +
      16. Review wiki/log.md, lnk memory-log ~/link, and lnk wins ~/link.
      17. Only share reviewed wiki/ pages whose memories are marked visibility: project or visibility: team.
      diff --git a/docs/troubleshooting.html b/docs/troubleshooting.html index ad90975..9c28456 100644 --- a/docs/troubleshooting.html +++ b/docs/troubleshooting.html @@ -57,32 +57,32 @@

      Start with status, then repair deliberately.

      Is Link Ready?

      -
      link health
      -link status --validate
      -link doctor
      -link next
      +
      lnk health
      +lnk status --validate
      +lnk doctor
      +lnk next

      If link is not on your PATH, run from the source checkout with python3 link.py, or add ~/.local/bin to your shell path.

      MCP Is Not Visible

      -
      link verify-mcp
      +        
      lnk verify-mcp
       python3 -m pip index versions link-mcp

      Restart the MCP client after changing its config. If your installer printed a venv Python path, use that exact path in the MCP config.

      -

      You do not need link serve or serve.py running for MCP. The web viewer is separate from the MCP server.

      +

      You do not need lnk serve or serve.py running for MCP. The web viewer is separate from the MCP server.

      Ingest Is Blocked

      -
      link ingest-status
      +
      lnk ingest-status

      Blocked ingest usually means a raw source has secret-looking values, cannot be read safely, or source representation counts may be incomplete. Redact or fix the local file, then ask the agent to ingest again.

      Interrupted Writes

      -
      link operations
      -link validate
      -link doctor --fix
      +
      lnk operations
      +lnk validate
      +lnk doctor --fix

      If a memory write was interrupted, Link leaves a marker under wiki/.link-operations/. Inspect it before deleting anything manually, then validate the wiki and repair generated indexes if needed.

      Graph Is Stale

      -
      link rebuild-index
      -link rebuild-backlinks
      -link validate
      +
      lnk rebuild-index
      +lnk rebuild-backlinks
      +lnk validate

      Run this after manual Obsidian edits, hand-written wikilinks, or a failed ingest.

      Demo Looks Stale

      @@ -93,10 +93,10 @@

      Demo Looks Stale

      The current generated demo should include three raw sources, source-backed wiki pages, one starter memory, one exploration, current backlinks, and schema v1.

      The Wiki Feels Slow

      -
      link benchmark "agent memory"
      -link graph-summary "agent memory" --limit 40 --depth 1
      +
      lnk benchmark "agent memory"
      +lnk graph-summary "agent memory" --limit 40 --depth 1

      Large graph views intentionally open bounded first. Use type filters, node search, depth controls, and explicit all-data loading only when you need search or filtering across every page.

      -

      If link health or link benchmark shows low persistent-cache reuse after repeated runs, inspect recently edited pages or generated files that are changing on every request.

      +

      If lnk health or lnk benchmark shows low persistent-cache reuse after repeated runs, inspect recently edited pages or generated files that are changing on every request.

      From a source checkout, use the synthetic smoke script when you want local scale evidence without touching your real wiki:

      python3 scripts/smoke_large_wiki.py --pages 10000
      diff --git a/docs/ui.html b/docs/ui.html index 4baca4d..fa15e61 100644 --- a/docs/ui.html +++ b/docs/ui.html @@ -84,10 +84,10 @@

      Graph At Scale

      Health Page

      -

      Open /health when the wiki feels wrong. It gathers the same readiness signals as link status --validate, plus persistent-cache reuse, interrupted operation markers, and repair commands you can copy back into your terminal or agent chat.

      +

      Open /health when the wiki feels wrong. It gathers the same readiness signals as lnk status --validate, plus persistent-cache reuse, interrupted operation markers, and repair commands you can copy back into your terminal or agent chat.

      Start It

      -
      link serve
      +        
      lnk serve
       
       # from a source checkout
       python3 link.py serve link-demo
      diff --git a/integrations/README.md b/integrations/README.md index 09454cc..1e09c8b 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -49,7 +49,7 @@ query Link for what you know about this project 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 a short `link` command wrapper for global installs, so checks are short: `link health`. +5. Adds a short `lnk` command wrapper for global installs, so checks are short: `lnk health`. 6. Prints next prompts and verification commands for your install mode. The instruction file is intentionally small. It tells the agent to check diff --git a/integrations/_shared/instructions.ps1 b/integrations/_shared/instructions.ps1 index 3cbcd75..a33dc52 100644 --- a/integrations/_shared/instructions.ps1 +++ b/integrations/_shared/instructions.ps1 @@ -147,8 +147,8 @@ function Link-PrintNextSteps { Write-Host " ingest raw/ into Link" } else { Write-Host " Drop sources into ~/link/raw/." - Write-Host " View wiki: link serve" - Write-Host " Print starter prompts: link next" + Write-Host " View wiki: lnk serve" + Write-Host " Print starter prompts: lnk next" Write-Host " Try in your agent:" Write-Host " is Link ready?" Write-Host " brief me from Link before we continue" diff --git a/integrations/_shared/instructions.sh b/integrations/_shared/instructions.sh index 4ef4c7d..4c63f00 100644 --- a/integrations/_shared/instructions.sh +++ b/integrations/_shared/instructions.sh @@ -52,8 +52,8 @@ link_print_next_steps() { echo " ingest raw/ into Link" else echo " Drop sources into ~/link/raw/." - echo " View wiki: link serve" - echo " Print starter prompts: link next" + echo " View wiki: lnk serve" + echo " Print starter prompts: lnk next" echo " Try in your agent:" echo " is Link ready?" echo " brief me from Link before we continue" diff --git a/integrations/_shared/link-instructions.md b/integrations/_shared/link-instructions.md index b86e837..e90a856 100644 --- a/integrations/_shared/link-instructions.md +++ b/integrations/_shared/link-instructions.md @@ -2,23 +2,23 @@ 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/`. -If you are unsure whether Link is ready, use MCP `link_status` when available, or run `link health`. +If you are unsure whether Link is ready, use MCP `link_status` when available, or run `lnk health`. -If the user asks what to try after installing Link, use MCP `starter_prompts` when available, or run `link next`. +If the user asks what to try after installing Link, use MCP `starter_prompts` when available, or run `lnk next`. -If status reports a missing or old schema marker, use MCP `migrate_wiki` when available, or run `link migrate`, before other writes. +If status reports a missing or old schema marker, use MCP `migrate_wiki` when available, or run `lnk migrate`, before other writes. -When the user asks to ingest or drops files into `raw/`, use MCP `ingest_status` when available, or run `link ingest-status`, then follow its guided plan before deciding what to process. If it reports `blocked_secrets` or secret warnings, do not read or ingest flagged raw files until the user redacts them. +When the user asks to ingest or drops files into `raw/`, use MCP `ingest_status` when available, or run `lnk ingest-status`, then follow its guided plan before deciding what to process. If it reports `blocked_secrets` or secret warnings, do not read or ingest flagged raw files until the user redacts them. -When answering a substantive question that may need local memory or wiki context, start with MCP `query_link` when available, or run `link query ""`. +When answering a substantive question that may need local memory or wiki context, start with MCP `query_link` when available, or run `lnk query ""`. -When you only need graph orientation, especially for a large wiki, prefer MCP `get_graph_summary` or `link graph-summary ""` before requesting the full graph. +When you only need graph orientation, especially for a large wiki, prefer MCP `get_graph_summary` or `lnk graph-summary ""` before requesting the full graph. -When starting personalized or project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `link brief ""`. +When starting personalized or project-specific work, prime yourself with Link first: use MCP `memory_brief` when available, or run `lnk brief ""`. -Before broad repairs or risky local wiki edits, create a local backup with MCP `backup_wiki` when available, or run `link backup`. Do not include `raw/` unless the user explicitly asks. +Before broad repairs or risky local wiki edits, create a local backup with MCP `backup_wiki` when available, or run `lnk backup`. Do not include `raw/` unless the user explicitly asks. -After ingesting raw sources or making substantial wiki edits, use MCP `rebuild_index`, `rebuild_backlinks`, and `validate_wiki` when available, or run `link rebuild-index`, `link rebuild-backlinks`, and `link validate`, before saying the wiki is updated. +After ingesting raw sources or making substantial wiki edits, use MCP `rebuild_index`, `rebuild_backlinks`, and `validate_wiki` when available, or run `lnk rebuild-index`, `lnk rebuild-backlinks`, and `lnk validate`, before saying the wiki is updated. 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. diff --git a/integrations/_shared/scaffold.ps1 b/integrations/_shared/scaffold.ps1 index 9ce64cf..66dc728 100644 --- a/integrations/_shared/scaffold.ps1 +++ b/integrations/_shared/scaffold.ps1 @@ -43,13 +43,22 @@ function Install-LinkCommandWrapper { } $cliDir = if ($env:LINK_CLI_DIR) { $env:LINK_CLI_DIR } else { Join-Path $HOME ".local\bin" } - $cmdPath = Join-Path $cliDir "link.cmd" - $psPath = Join-Path $cliDir "link.ps1" + $cmdPath = Join-Path $cliDir "lnk.cmd" + $psPath = Join-Path $cliDir "lnk.ps1" + $legacyCmdPath = Join-Path $cliDir "link.cmd" + $legacyPsPath = Join-Path $cliDir "link.ps1" $marker = "Link command wrapper" $linkPy = Join-Path $TargetDir "link.py" New-Item -ItemType Directory -Force -Path $cliDir | Out-Null + foreach ($legacyPath in @($legacyCmdPath, $legacyPsPath)) { + if ((Test-Path $legacyPath) -and (Select-String -Quiet -SimpleMatch $marker $legacyPath)) { + Remove-Item -Force $legacyPath + Write-Host " Removed old Link wrapper: $legacyPath" + } + } + if ((Test-Path $cmdPath) -and -not (Select-String -Quiet -SimpleMatch $marker $cmdPath)) { Write-Host " · $cmdPath already exists and is not a Link wrapper; not overwriting." Write-Host " Fallback: $BasePython `"$linkPy`" health" @@ -73,7 +82,7 @@ exit `$LASTEXITCODE Write-Host " ✓ Link command: $cmdPath" $pathParts = ($env:PATH -split [IO.Path]::PathSeparator) if ($pathParts -notcontains $cliDir) { - Write-Host " · Add $cliDir to PATH to run: link health" + Write-Host " · Add $cliDir to PATH to run: lnk health" } } @@ -220,8 +229,8 @@ if (Test-Path (Join-Path $TargetDir "link.py")) { Write-Host " py link.py verify-mcp" } else { Write-Host " Check Link readiness:" - Write-Host " link health" + Write-Host " lnk health" Write-Host " Verify MCP setup:" - Write-Host " link verify-mcp" + Write-Host " lnk verify-mcp" } } diff --git a/integrations/_shared/scaffold.sh b/integrations/_shared/scaffold.sh index aa3f9c0..1fc9eb3 100755 --- a/integrations/_shared/scaffold.sh +++ b/integrations/_shared/scaffold.sh @@ -31,11 +31,17 @@ install_link_cli_wrapper() { fi LINK_CLI_DIR="${LINK_CLI_DIR:-$HOME/.local/bin}" - LINK_CLI_BIN="$LINK_CLI_DIR/link" + LINK_CLI_BIN="$LINK_CLI_DIR/lnk" + LEGACY_LINK_CLI_BIN="$LINK_CLI_DIR/link" LINK_CLI_MARKER="# Link command wrapper" mkdir -p "$LINK_CLI_DIR" + if [ -e "$LEGACY_LINK_CLI_BIN" ] && grep -q "$LINK_CLI_MARKER" "$LEGACY_LINK_CLI_BIN" 2>/dev/null; then + rm -f "$LEGACY_LINK_CLI_BIN" + echo " Removed old Link wrapper: $LEGACY_LINK_CLI_BIN" + fi + if [ -e "$LINK_CLI_BIN" ] && ! grep -q "$LINK_CLI_MARKER" "$LINK_CLI_BIN" 2>/dev/null; then echo " · $LINK_CLI_BIN already exists and is not a Link wrapper; not overwriting." echo " Fallback: cd \"$TARGET_DIR\" && python3 link.py health" @@ -54,9 +60,9 @@ EOF echo " ✓ Link command: $LINK_CLI_BIN" - RESOLVED_LINK="$(command -v link 2>/dev/null || true)" + RESOLVED_LINK="$(command -v lnk 2>/dev/null || true)" if [ "$RESOLVED_LINK" != "$LINK_CLI_BIN" ]; then - echo " · Add $LINK_CLI_DIR to the front of PATH to run: link health" + echo " · Add $LINK_CLI_DIR to the front of PATH to run: lnk health" fi } @@ -230,18 +236,18 @@ if [ -f "$TARGET_DIR/link.py" ]; then echo " python3 link.py rebuild-backlinks" else echo " Check Link readiness:" - echo " link health" + echo " lnk health" echo " Print starter prompts:" - echo " link next" + echo " lnk next" echo " Check wiki health:" - echo " link doctor" + echo " lnk doctor" echo " Create a local backup:" - echo " link backup" + echo " lnk backup" echo " Validate ingest output:" - echo " link validate" + echo " lnk validate" echo " Verify MCP setup:" - echo " link verify-mcp" + echo " lnk verify-mcp" echo " Repair stale graph index:" - echo " link rebuild-backlinks" + echo " lnk rebuild-backlinks" fi fi diff --git a/mcp_package/link_core/benchmark.py b/mcp_package/link_core/benchmark.py index d456044..351532f 100644 --- a/mcp_package/link_core/benchmark.py +++ b/mcp_package/link_core/benchmark.py @@ -184,7 +184,7 @@ def benchmark_health(payload: Mapping[str, object]) -> dict[str, object]: if warnings: summary = "Review recommended before relying on this wiki for interactive agent work." recommendations = [ - "Run link doctor --fix and link benchmark again after repairing wiki/index state.", + "Run lnk doctor --fix and lnk benchmark again after repairing wiki/index state.", ] if large_token_fallback or "search" in slow_paths or "query" in slow_paths: recommendations.append("Use a Python build with sqlite3/FTS5 enabled for large local wikis.") diff --git a/mcp_package/link_core/ingest.py b/mcp_package/link_core/ingest.py index 7c7bc42..cc260fc 100644 --- a/mcp_package/link_core/ingest.py +++ b/mcp_package/link_core/ingest.py @@ -156,7 +156,7 @@ def _target_command(command: str, target: str) -> str: if target and target in command: return command parts = command.split() - if not parts or parts[0] != "link" or not target: + if not parts or parts[0] not in {"link", "lnk"} or not target: return command if target in parts[1:]: return display_command(parts) @@ -268,10 +268,10 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob "agent_prompt": guidance.get("agent_prompt"), "memory_prompt": f"propose memories from {first['raw']}", "post_checks": [ - "link rebuild-index", - "link rebuild-backlinks", - "link validate", - "link health", + "lnk rebuild-index", + "lnk rebuild-backlinks", + "lnk validate", + "lnk health", ], } @@ -294,10 +294,10 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob "agent_prompt": guidance.get("agent_prompt"), "memory_prompt": f"propose memories from {first['raw']}", "post_checks": [ - "link rebuild-index", - "link rebuild-backlinks", - "link validate", - "link health", + "lnk rebuild-index", + "lnk rebuild-backlinks", + "lnk validate", + "lnk health", ], } @@ -325,7 +325,7 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob ], "agent_prompt": None, "memory_prompt": None, - "post_checks": ["link ingest-status", "link health"], + "post_checks": ["lnk ingest-status", "lnk health"], } if state == "blocked_raw_access": @@ -352,7 +352,7 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob ], "agent_prompt": None, "memory_prompt": None, - "post_checks": ["link ingest-status", "link health"], + "post_checks": ["lnk ingest-status", "lnk health"], } if state == "blocked_source_access": @@ -378,7 +378,7 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob ], "agent_prompt": None, "memory_prompt": None, - "post_checks": ["link ingest-status", "link validate", "link health"], + "post_checks": ["lnk ingest-status", "lnk validate", "lnk health"], } if state == "stale_graph": @@ -392,7 +392,7 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob "Validate the wiki after rebuilding backlinks.", ], "agent_prompt": guidance.get("agent_prompt"), - "post_checks": ["link rebuild-backlinks", "link validate", "link health"], + "post_checks": ["lnk rebuild-backlinks", "lnk validate", "lnk health"], } if state == "empty": @@ -407,7 +407,7 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob "Review generated pages before relying on them as memory.", ], "agent_prompt": None, - "post_checks": ["link ingest-status", "link health"], + "post_checks": ["lnk ingest-status", "lnk health"], } if state == "ready": @@ -421,7 +421,7 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob "Add new files to raw/ when Link should learn new source-backed context.", ], "agent_prompt": None, - "post_checks": ["link doctor", "link health"], + "post_checks": ["lnk doctor", "lnk health"], } return { @@ -430,11 +430,11 @@ def build_ingest_plan(status: dict[str, object], limit: int = 5) -> dict[str, ob "summary": "Link needs its raw/ and wiki/ structure before ingest can start.", "batch": [], "steps": [ - "Run link init or rerun an installer.", + "Run lnk init or rerun an installer.", "Check readiness before adding sources.", ], "agent_prompt": None, - "post_checks": ["link init", "link health"], + "post_checks": ["lnk init", "lnk health"], } @@ -497,7 +497,7 @@ def render_ingest_status_text(target: str, status: dict[str, object]) -> str: if not status["has_wiki_dir"]: lines.append("Missing wiki/ directory") if not status["has_raw_dir"] or not status["has_wiki_dir"]: - lines.extend(["", "Next:", f" Run an installer or initialize this directory: {_target_command('link init', target)}"]) + lines.extend(["", "Next:", f" Run an installer or initialize this directory: {_target_command('lnk init', target)}"]) return "\n".join(lines) lines.append(f"Raw files: {status['raw_count']}") @@ -626,7 +626,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "state": "missing_structure", "summary": "Link is not initialized here yet.", "agent_prompt": None, - "commands": ["link init", "link health"], + "commands": ["lnk init", "lnk health"], "notes": ["Run the installer or initialize this directory before ingesting sources."], } @@ -635,7 +635,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "state": "blocked_source_access", "summary": f"{source_read_warning_count} source page could not be inspected. Fix source page access before ingest.", "agent_prompt": None, - "commands": ["link ingest-status", "link validate", "link health"], + "commands": ["lnk ingest-status", "lnk validate", "lnk health"], "notes": [ "Represented and pending raw counts may be incomplete while source pages cannot be read.", "Fix permissions or repair the page, then refresh ingest status.", @@ -652,7 +652,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "state": "blocked_raw_access", "summary": summary + f" Fix access for {first} before ingest.", "agent_prompt": None, - "commands": ["link ingest-status", "link health"], + "commands": ["lnk ingest-status", "lnk health"], "notes": [ "Do not ask an agent to ingest raw files that Link cannot read and scan for secret-looking values.", "Fix permissions or replace the file, then refresh ingest status.", @@ -669,7 +669,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "state": "blocked_secrets", "summary": summary + f" Redact {first} before ingest.", "agent_prompt": None, - "commands": ["link ingest-status", "link health"], + "commands": ["lnk ingest-status", "lnk health"], "notes": [ "Do not ask an agent to ingest flagged raw files until the secret-looking values are removed or redacted.", "After redaction, refresh ingest status and continue with the normal ingest prompt.", @@ -686,7 +686,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "state": "stale_raw", "summary": summary, "agent_prompt": f"re-ingest {first} into Link", - "commands": ["link rebuild-index", "link rebuild-backlinks", "link validate", "link health"], + "commands": ["lnk rebuild-index", "lnk rebuild-backlinks", "lnk validate", "lnk health"], "notes": [ "The raw file is represented, but it is newer than the linked source page.", "Ask the agent to refresh the existing source page before relying on retrieval.", @@ -705,7 +705,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "state": "pending_raw", "summary": summary, "agent_prompt": f"ingest {first} into Link", - "commands": ["link rebuild-index", "link rebuild-backlinks", "link validate", "link health"], + "commands": ["lnk rebuild-index", "lnk rebuild-backlinks", "lnk validate", "lnk health"], "notes": [ "If the source contains user preferences, decisions, or project context, ask for memory proposals before saving durable memories.", "After ingest, rebuild index/backlinks if your agent did not already do it.", @@ -717,7 +717,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "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"], + "commands": ["lnk rebuild-backlinks", "lnk validate", "lnk doctor"], "notes": ["Run the graph repair before relying on context or graph views."], } @@ -726,7 +726,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "state": "empty", "summary": "Link is ready, but raw/ has no source files yet.", "agent_prompt": None, - "commands": ["link health", "link serve"], + "commands": ["lnk health", "lnk serve"], "notes": ["Drop notes, articles, transcripts, or project files into raw/, then ask your agent to ingest them into Link."], } @@ -734,7 +734,7 @@ def build_ingest_guidance(status: dict[str, object]) -> dict[str, object]: "state": "ready", "summary": "All raw files are represented in wiki/sources and the graph index is current.", "agent_prompt": None, - "commands": ["link doctor", "link health"], + "commands": ["lnk doctor", "lnk health"], "notes": ["Add new files to raw/ when you want Link to learn new source-backed knowledge."], } diff --git a/mcp_package/link_core/mcp_connect.py b/mcp_package/link_core/mcp_connect.py index 7b24bac..bf22456 100644 --- a/mcp_package/link_core/mcp_connect.py +++ b/mcp_package/link_core/mcp_connect.py @@ -8,7 +8,7 @@ from typing import Any, Mapping from .files import atomic_write_json, atomic_write_text -from .mcp_verify import display_command, resolve_mcp_python +from .mcp_verify import display_command, normalize_command_parts, resolve_mcp_python @dataclass(frozen=True) @@ -83,7 +83,7 @@ class AgentMcpConfig: def supported_agents() -> tuple[str, ...]: - """Return canonical agent names supported by `link connect`.""" + """Return canonical agent names supported by `lnk connect`.""" return tuple(config.name for config in AGENT_CONFIGS) @@ -93,7 +93,7 @@ def _agent_by_name(agent: str) -> AgentMcpConfig: if normalized == config.name or normalized in config.aliases: return config choices = ", ".join(supported_agents()) - raise ValueError(f"unsupported agent for link connect: {agent}. Try one of: {choices}") + raise ValueError(f"unsupported agent for lnk connect: {agent}. Try one of: {choices}") def _config_path(default_config: str, override: str | None) -> Path: @@ -196,7 +196,7 @@ def build_mcp_connect_payload( except Exception as exc: write_status = {"requested": True, "ok": False, "message": str(exc)} - connect_command = ["link", "connect", config.name, str(target)] + connect_command = ["lnk", "connect", config.name, str(target)] if config_path: connect_command.extend(["--config", str(path)]) if python_cmd: @@ -223,12 +223,12 @@ def build_mcp_connect_payload( }, { "label": "verify MCP runtime", - "command": ["link", "verify-mcp", str(target), "--python", resolved_python], - "command_text": display_command(["link", "verify-mcp", str(target), "--python", resolved_python]), + "command": ["lnk", "verify-mcp", str(target), "--python", resolved_python], + "command_text": display_command(["lnk", "verify-mcp", str(target), "--python", resolved_python]), }, { "label": "create wiki if missing", - "command": init_command, + "command": normalize_command_parts(init_command), "command_text": display_command(init_command), }, ], diff --git a/mcp_package/link_core/mcp_verify.py b/mcp_package/link_core/mcp_verify.py index dda4d74..0a6562c 100644 --- a/mcp_package/link_core/mcp_verify.py +++ b/mcp_package/link_core/mcp_verify.py @@ -9,14 +9,27 @@ from typing import Callable, Mapping +PREFERRED_LINK_COMMAND = "lnk" +LEGACY_LINK_COMMAND = "link" + + +def normalize_command_parts(parts: list[str]) -> list[str]: + """Use Link's non-conflicting CLI command name in generated user commands.""" + if parts and parts[0] == LEGACY_LINK_COMMAND: + return [PREFERRED_LINK_COMMAND, *parts[1:]] + return list(parts) + + def display_command(parts: list[str]) -> str: """Return a shell-safe command for the current platform.""" + parts = normalize_command_parts(parts) if os.name == "nt": return subprocess.list2cmdline(parts) return shlex.join(parts) def mcp_verify_action(tool: str, label: str, command: list[str]) -> dict[str, object]: + command = normalize_command_parts(command) return { "tool": tool, "label": label, diff --git a/mcp_package/link_core/memory_log.py b/mcp_package/link_core/memory_log.py index 12d1b3e..7cdcfa3 100644 --- a/mcp_package/link_core/memory_log.py +++ b/mcp_package/link_core/memory_log.py @@ -59,12 +59,12 @@ def memory_log_payload( "next_actions": [ { "label": "Inspect memory review queue", - "command": "link memory-inbox", + "command": "lnk memory-inbox", "reason": "Review pending, stale, expired, or underspecified memories before relying on them.", }, { "label": "Explain a changed memory", - "command": "link explain-memory ", + "command": "lnk explain-memory ", "reason": "Open provenance, review status, graph links, and matching log entries for one memory.", }, ], diff --git a/mcp_package/link_core/memory_wins.py b/mcp_package/link_core/memory_wins.py index 1ac135c..32cb52b 100644 --- a/mcp_package/link_core/memory_wins.py +++ b/mcp_package/link_core/memory_wins.py @@ -211,12 +211,12 @@ def _next_actions( { "label": "Create first memory", "reason": "A wins report becomes useful after Link has at least one durable memory.", - "command": f'link remember "I prefer ..." {target} --type preference --scope user', + "command": f'lnk remember "I prefer ..." {target} --type preference --scope user', }, { "label": "Propose from sources", "reason": "Use source-backed proposals when raw notes contain preferences or decisions.", - "command": f"link propose-memories raw/.md {target}", + "command": f"lnk propose-memories raw/.md {target}", }, ] if review_count: @@ -224,7 +224,7 @@ def _next_actions( { "label": "Review memory inbox", "reason": "Reviewed memory is safer to reuse across agents.", - "command": f"link memory-inbox {target}{project_arg}", + "command": f"lnk memory-inbox {target}{project_arg}", } ] if active_count: @@ -232,13 +232,13 @@ def _next_actions( { "label": "Use the memory", "reason": "Ask an agent for a brief before work to see the value loop.", - "command": f"link brief \"current task\" {target}{project_arg}", + "command": f"lnk brief \"current task\" {target}{project_arg}", } ] return [ { "label": "Restore or create memory", "reason": "No active memories are currently available for default recall.", - "command": f"link profile {target}{project_arg}", + "command": f"lnk profile {target}{project_arg}", } ] diff --git a/mcp_package/link_core/obsidian.py b/mcp_package/link_core/obsidian.py index 7011dcb..28783ea 100644 --- a/mcp_package/link_core/obsidian.py +++ b/mcp_package/link_core/obsidian.py @@ -101,9 +101,9 @@ def import_obsidian_vault( elif skipped_large: status = "partial" has_wiki_dir = (root / "wiki").is_dir() - next_commands = [f"link ingest-status {root}", f"link validate {root}", f"link health {root}"] + next_commands = [f"lnk ingest-status {root}", f"lnk validate {root}", f"lnk health {root}"] if not has_wiki_dir: - next_commands = [f"link init {root}", *next_commands] + next_commands = [f"lnk init {root}", *next_commands] return { "status": status, "target": str(root), diff --git a/mcp_package/link_core/share.py b/mcp_package/link_core/share.py index ce6c783..a5a8f32 100644 --- a/mcp_package/link_core/share.py +++ b/mcp_package/link_core/share.py @@ -95,7 +95,7 @@ def share_page_payload( summary = _page_summary(page) page_name = str(summary["name"]) root = wiki_dir.parent if wiki_dir.name == "wiki" else wiki_dir - serve_command = ["link", "serve", str(root), "--port", str(port)] + serve_command = ["lnk", "serve", str(root), "--port", str(port)] return { "found": True, "query": query, diff --git a/mcp_package/link_core/snapshot.py b/mcp_package/link_core/snapshot.py index 480765e..1af6254 100644 --- a/mcp_package/link_core/snapshot.py +++ b/mcp_package/link_core/snapshot.py @@ -283,7 +283,7 @@ def export_snapshot( if sensitive_values or sensitive_read_errors: return { "created": False, - "error": "wiki contains secret-looking values or unreadable files; run link doctor before exporting", + "error": "wiki contains secret-looking values or unreadable files; run lnk doctor before exporting", "output": str(output_dir), "sensitive_values": sensitive_values, "read_errors": sensitive_read_errors, @@ -389,7 +389,7 @@ def render_snapshot_text(payload: Mapping[str, object]) -> tuple[int, str]: lines.append("") lines.append("Unreadable files:") lines.extend(f"- {item}" for item in read_errors[:8]) - lines.extend(["", "Next:", " link doctor"]) + lines.extend(["", "Next:", " lnk doctor"]) return 1, "\n".join(lines) lines = [ diff --git a/mcp_package/link_core/team_sync.py b/mcp_package/link_core/team_sync.py index 7f2cab5..43df1fb 100644 --- a/mcp_package/link_core/team_sync.py +++ b/mcp_package/link_core/team_sync.py @@ -148,7 +148,7 @@ def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[ warnings: list[str] = [] if not wiki_dir.exists(): - warnings.append("Link wiki is missing. Run link init before preparing team sync.") + warnings.append("Link wiki is missing. Run lnk init before preparing team sync.") if git_root and not bool(gitignore.get("protects_raw")): warnings.append("raw/ is not protected by the workspace .gitignore; do not push until raw sources are intentionally handled.") if git_root and not remotes and not remote_clean: @@ -160,10 +160,10 @@ def build_team_sync_payload(target: Path, *, remote: str | None = None) -> dict[ setup_actions: list[dict[str, str]] = [] sync_actions: list[dict[str, str]] = [ - _action("check Link health", ["link", "health", str(root)]), - _action("review pending memories", ["link", "memory-inbox", str(root)]), - _action("validate before sharing", ["link", "validate", str(root)]), - _action("backup before sharing", ["link", "backup", str(root)]), + _action("check Link health", ["lnk", "health", str(root)]), + _action("review pending memories", ["lnk", "memory-inbox", str(root)]), + _action("validate before sharing", ["lnk", "validate", str(root)]), + _action("backup before sharing", ["lnk", "backup", str(root)]), ] if git_root is None: setup_actions.extend([ diff --git a/mcp_package/link_core/web_health.py b/mcp_package/link_core/web_health.py index 1aa1e65..ec23fb5 100644 --- a/mcp_package/link_core/web_health.py +++ b/mcp_package/link_core/web_health.py @@ -193,7 +193,7 @@ def _render_health_cards(status: Mapping[str, object], operations: Mapping[str, validation_detail = ( f"{int(validation.get('error_count') or 0)} errors · {int(validation.get('warning_count') or 0)} warnings" if validation_checked - else "run link health" + else "run lnk health" ) cards = [ ( diff --git a/mcp_package/link_core/web_propose.py b/mcp_package/link_core/web_propose.py index 237cd87..93a3e34 100644 --- a/mcp_package/link_core/web_propose.py +++ b/mcp_package/link_core/web_propose.py @@ -58,7 +58,7 @@ def _render_proposal_path() -> str: 'remember that ...
      ' '
      4' '

      Review later

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

      ' - 'link memory-inbox
      ' + 'lnk memory-inbox' '' ) diff --git a/mcp_package/link_core/wiki.py b/mcp_package/link_core/wiki.py index f50e25e..69f5ecb 100644 --- a/mcp_package/link_core/wiki.py +++ b/mcp_package/link_core/wiki.py @@ -1040,7 +1040,7 @@ def rebuild_index( "next_actions": [ { "tool": "rebuild_backlinks", - "command": "link rebuild-backlinks", + "command": "lnk rebuild-backlinks", "reason": "Regenerated index links change graph edges; rebuild backlinks before validation.", } ], diff --git a/mcp_package/link_mcp/server.py b/mcp_package/link_mcp/server.py index 4d59ed9..5f9f46f 100644 --- a/mcp_package/link_mcp/server.py +++ b/mcp_package/link_mcp/server.py @@ -50,7 +50,7 @@ if not WIKI_DIR.exists(): print( f"[link-mcp] Wiki not found at {WIKI_DIR}. " - "Initialize Link first with `link init` or `python3 link.py init`, " + "Initialize Link first with `lnk init` or `python3 link.py init`, " "run an integration installer under integrations/*/install.sh, " "or pass --wiki /path/to/wiki.", file=sys.stderr, diff --git a/packaging/homebrew/Formula/link.rb b/packaging/homebrew/Formula/link.rb index 797e258..bc19dd4 100644 --- a/packaging/homebrew/Formula/link.rb +++ b/packaging/homebrew/Formula/link.rb @@ -21,7 +21,7 @@ def install (libexec/"mcp_package").mkpath (libexec/"mcp_package").install "mcp_package/link_core" - (bin/"link").write <<~SH + (bin/"lnk").write <<~SH #!/bin/sh exec "#{python3}" "#{libexec}/link.py" "$@" SH @@ -30,15 +30,15 @@ def install def caveats <<~EOS Try Link: - link demo - link serve link-demo + lnk demo + lnk serve link-demo Then open: http://127.0.0.1:3000 http://127.0.0.1:3000/graph To create a personal wiki: - link init ~/link + lnk init ~/link For MCP clients, install link-mcp with the agent installer or a venv: python3 -m venv ~/.link-mcp-venv @@ -47,9 +47,9 @@ def caveats end test do - system bin/"link", "--version" - system bin/"link", "demo", testpath/"link-demo", "--force" - system bin/"link", "validate", testpath/"link-demo" - system bin/"link", "status", "--validate", testpath/"link-demo" + system bin/"lnk", "--version" + system bin/"lnk", "demo", testpath/"link-demo", "--force" + system bin/"lnk", "validate", testpath/"link-demo" + system bin/"lnk", "status", "--validate", testpath/"link-demo" end end diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md index 05ecb2e..eae3cef 100644 --- a/packaging/homebrew/README.md +++ b/packaging/homebrew/README.md @@ -32,8 +32,8 @@ Validate locally: brew audit --strict --online gowtham0992/link/link brew install --build-from-source gowtham0992/link/link brew test gowtham0992/link/link -link --version -link demo +lnk --version +lnk demo ``` Then push the tap repo: diff --git a/scripts/check_tool_contract.py b/scripts/check_tool_contract.py index 5afd355..a7a4064 100644 --- a/scripts/check_tool_contract.py +++ b/scripts/check_tool_contract.py @@ -179,8 +179,10 @@ def _missing_cli_reference(path: Path = ROOT / CLI_DOC_PATH) -> list[str]: missing: list[str] = [] for command in sorted(DOCS_CLI_COMMANDS): command_tokens = ( + f"`lnk {command}", f"`link {command}", f"`python3 link.py {command}", + f"lnk {command}", f"link {command}", f"python3 link.py {command}", ) diff --git a/tests/test_benchmark_core.py b/tests/test_benchmark_core.py index 40aecef..5284a50 100644 --- a/tests/test_benchmark_core.py +++ b/tests/test_benchmark_core.py @@ -81,7 +81,7 @@ def test_benchmark_health_warns_on_slow_paths(self): self.assertEqual(health["label"], "review") self.assertIn("search took 1.5000s", health["warnings"][0]) self.assertIn("Review recommended", health["summary"]) - self.assertIn("Run link doctor --fix", health["recommendations"][0]) + self.assertIn("Run lnk doctor --fix", health["recommendations"][0]) self.assertIn("sqlite3/FTS5", health["recommendations"][1]) def test_benchmark_health_warns_on_large_token_fallback(self): diff --git a/tests/test_cli_admin_core.py b/tests/test_cli_admin_core.py index 0507ab3..940453f 100644 --- a/tests/test_cli_admin_core.py +++ b/tests/test_cli_admin_core.py @@ -73,7 +73,7 @@ def test_render_status_not_ready(self): self.assertIn("Ready: no", text) self.assertIn("Missing: wiki/index.md", text) self.assertIn("migrate_wiki: migrate schema", text) - self.assertIn("Run: link migrate /tmp/link", text) + self.assertIn("Run: lnk migrate /tmp/link", text) def test_render_status_ready_includes_human_query_command(self): code, text = render_status_text({ @@ -94,7 +94,7 @@ def test_render_status_ready_includes_human_query_command(self): self.assertEqual(code, 0) self.assertIn("query_link: answer with compact local context", text) - self.assertIn("Run: link query", text) + self.assertIn("Run: lnk query", text) self.assertIn("what should I know before continuing?", text) self.assertIn("/tmp/link", text) diff --git a/tests/test_cli_memory_core.py b/tests/test_cli_memory_core.py index 1732aec..4776515 100644 --- a/tests/test_cli_memory_core.py +++ b/tests/test_cli_memory_core.py @@ -269,7 +269,7 @@ def test_render_memory_inbox(self): "kind": "review", "label": "Review", "description": "Mark memory reviewed", - "command": "link review-memory prefer-local-memory", + "command": "lnk review-memory prefer-local-memory", }, "actions": [ {"kind": "review", "label": "Review"}, @@ -463,7 +463,7 @@ def test_render_memory_audit_text(self): "next_actions": [{ "label": "Review memory inbox", "recommended": True, - "command": "link memory-inbox", + "command": "lnk memory-inbox", }], }, target="/tmp/link") diff --git a/tests/test_cli_query_core.py b/tests/test_cli_query_core.py index 9990dde..e6b3290 100644 --- a/tests/test_cli_query_core.py +++ b/tests/test_cli_query_core.py @@ -14,15 +14,15 @@ def test_render_query_not_found(self): self.assertIn("No Link context found for: missing", text) self.assertIn("Next:", text) self.assertIn("ingest the new raw Link files", text) - self.assertIn("Run: link ingest-status", text) - self.assertIn("Then rerun: link query missing", text) + self.assertIn("Run: lnk ingest-status", text) + self.assertIn("Then rerun: lnk query missing", text) def test_render_query_not_found_can_include_explicit_target(self): code, text = render_query_text({"found": False}, query_text="missing topic", command_target="/tmp/Link Demo") self.assertEqual(code, 0) - self.assertIn("Run: link ingest-status", text) - self.assertIn("Then rerun: link query", text) + self.assertIn("Run: lnk ingest-status", text) + self.assertIn("Then rerun: lnk query", text) self.assertIn("missing topic", text) self.assertIn("/tmp/Link Demo", text) diff --git a/tests/test_cli_runtime_core.py b/tests/test_cli_runtime_core.py index 0341a00..84ad040 100644 --- a/tests/test_cli_runtime_core.py +++ b/tests/test_cli_runtime_core.py @@ -17,28 +17,28 @@ def test_render_init_text(self): self.assertEqual(code, 0) self.assertIn("Link wiki ready at /tmp/link", text) self.assertIn("Initialized:", text) - self.assertIn("link health /tmp/link", text) - self.assertIn("link serve /tmp/link", text) + self.assertIn("lnk health /tmp/link", text) + self.assertIn("lnk serve /tmp/link", text) def test_render_starter_prompts_text(self): code, text = render_starter_prompts_text({ "target": "/tmp/link", "project": "link", - "shortcut": "link next /tmp/link", + "shortcut": "lnk next /tmp/link", "prompts": [{ "prompt": "is Link ready?", "when": "first run", }], - "commands": ["link health"], + "commands": ["lnk health"], }) self.assertEqual(code, 0) self.assertIn("Link starter prompts: /tmp/link", text) self.assertIn("Project: link", text) self.assertIn("Shortcut", text) - self.assertIn("- link next /tmp/link", text) + self.assertIn("- lnk next /tmp/link", text) self.assertIn("- is Link ready?", text) - self.assertIn("- link health", text) + self.assertIn("- lnk health", text) def test_render_welcome_text(self): code, text = render_welcome_text({ @@ -49,7 +49,7 @@ def test_render_welcome_text(self): "prompt": "is Link ready?", "proves": "Agent can find Link.", }], - "commands": ["link health"], + "commands": ["lnk health"], "urls": ["http://127.0.0.1:3000/health"], }) @@ -58,7 +58,7 @@ def test_render_welcome_text(self): self.assertIn("Project: link", text) self.assertIn("1. is Link ready?", text) self.assertIn("Proves: Agent can find Link.", text) - self.assertIn("- link health", text) + self.assertIn("- lnk health", text) self.assertIn("- http://127.0.0.1:3000/health", text) def test_render_demo_text(self): @@ -89,12 +89,12 @@ def test_render_try_text(self): search_backend="sqlite-fts", query_summary="agent-memory · 1 memories · 3 context items", brief_summary="1 relevant memories · 1 review items", - serve_command="link serve /tmp/link-demo", - next_command="link next /tmp/link-demo", - health_command="link health /tmp/link-demo", - query_command="link query 'why does Link help agents?' /tmp/link-demo --budget small", - brief_command="link brief 'working on agent memory' /tmp/link-demo", - benchmark_command="link benchmark 'agent memory' /tmp/link-demo", + serve_command="lnk serve /tmp/link-demo", + next_command="lnk next /tmp/link-demo", + health_command="lnk health /tmp/link-demo", + query_command="lnk query 'why does Link help agents?' /tmp/link-demo --budget small", + brief_command="lnk brief 'working on agent memory' /tmp/link-demo", + benchmark_command="lnk benchmark 'agent memory' /tmp/link-demo", url="http://127.0.0.1:3000", ) @@ -103,7 +103,7 @@ def test_render_try_text(self): self.assertIn("Demo: ready", text) self.assertIn("Query proof:", text) self.assertIn("Ask an agent:", text) - self.assertIn("link next /tmp/link-demo", text) + self.assertIn("lnk next /tmp/link-demo", text) def test_render_mcp_connect_text_preview(self): code, text = render_mcp_connect_text({ @@ -114,8 +114,8 @@ def test_render_mcp_connect_text_preview(self): "snippet": "[mcp_servers.link]\ncommand = \"/tmp/python\"", "write": {"requested": False, "ok": False, "message": "preview only"}, "next_actions": [ - {"label": "write config", "command_text": "link connect codex /tmp/link --write"}, - {"label": "verify MCP runtime", "command_text": "link verify-mcp /tmp/link --python /tmp/python"}, + {"label": "write config", "command_text": "lnk connect codex /tmp/link --write"}, + {"label": "verify MCP runtime", "command_text": "lnk verify-mcp /tmp/link --python /tmp/python"}, ], "restart_hint": "Restart the agent, then ask: is Link ready?", }) @@ -123,9 +123,9 @@ def test_render_mcp_connect_text_preview(self): self.assertEqual(code, 0) self.assertIn("Link connect: Codex", text) self.assertIn("Preview only", text) - self.assertIn("link connect codex /tmp/link --write", text) + self.assertIn("lnk connect codex /tmp/link --write", text) self.assertIn("[mcp_servers.link]", text) - self.assertIn("link verify-mcp /tmp/link --python /tmp/python", text) + self.assertIn("lnk verify-mcp /tmp/link --python /tmp/python", text) if __name__ == "__main__": diff --git a/tests/test_ingest_core.py b/tests/test_ingest_core.py index 21b9dad..04b4044 100644 --- a/tests/test_ingest_core.py +++ b/tests/test_ingest_core.py @@ -32,7 +32,7 @@ def test_collect_ingest_status_reports_missing_structure(self): self.assertFalse(payload["has_wiki_dir"]) self.assertEqual(payload["guidance"]["state"], "missing_structure") self.assertIn("Missing raw/ directory", text) - self.assertIn("Run an installer or initialize this directory: link init", text) + self.assertIn("Run an installer or initialize this directory: lnk init", text) def test_collect_ingest_status_reports_pending_raw(self): root = Path(tempfile.mkdtemp(prefix="link-ingest-core-")) @@ -56,16 +56,16 @@ def test_collect_ingest_status_reports_pending_raw(self): self.assertEqual(payload["plan"]["title"], "Ingest pending raw sources") self.assertEqual(payload["plan"]["batch"][0]["suggested_source_page"], "wiki/sources/new-note.md") self.assertEqual(payload["plan"]["memory_prompt"], "propose memories from raw/new-note.md") - self.assertTrue(any(command.startswith("link rebuild-index ") for command in payload["plan"]["post_checks"])) - self.assertIn(resolved_root, "\n".join(payload["plan"]["post_checks"])) + self.assertTrue(any(command.startswith("lnk rebuild-index ") for command in payload["plan"]["post_checks"])) text = render_ingest_status_text(str(root), payload) + self.assertIn(f"lnk rebuild-index {resolved_root}", text) self.assertIn(f"Link ingest status: {root}", text) self.assertIn("Raw files: 1", text) self.assertIn("Pending raw files:\n- raw/new-note.md", text) self.assertIn("Ask your agent: ingest raw/new-note.md into Link", text) - self.assertIn(f"Run: link rebuild-index {resolved_root}", text) - self.assertIn(f"- link health {resolved_root}", text) + self.assertIn(f"Run: lnk rebuild-index {resolved_root}", text) + self.assertIn(f"- lnk health {resolved_root}", text) self.assertIn("Suggested workflow: Ingest pending raw sources", text) self.assertIn("Memory review: propose memories from raw/new-note.md", text) diff --git a/tests/test_installers.py b/tests/test_installers.py index 1678505..06376ab 100644 --- a/tests/test_installers.py +++ b/tests/test_installers.py @@ -38,10 +38,12 @@ def test_scaffold_does_not_use_break_system_packages(self): def test_scaffold_installs_short_global_link_command(self): scaffold = (ROOT / "integrations/_shared/scaffold.sh").read_text(encoding="utf-8") - self.assertIn('LINK_CLI_BIN="$LINK_CLI_DIR/link"', scaffold) + self.assertIn('LINK_CLI_BIN="$LINK_CLI_DIR/lnk"', scaffold) + self.assertIn('LEGACY_LINK_CLI_BIN="$LINK_CLI_DIR/link"', scaffold) + self.assertIn("Removed old Link wrapper", scaffold) self.assertIn("Link command wrapper", scaffold) self.assertIn("not overwriting", scaffold) - self.assertIn("link health", scaffold) + self.assertIn("lnk health", scaffold) self.assertIn('if [ "$MODE" = "--project" ]', scaffold) def test_scaffold_project_mode_uses_absolute_target(self): @@ -56,7 +58,9 @@ def test_powershell_scaffold_uses_venv_and_short_link_command(self): self.assertNotIn("--break-system-packages", scaffold) self.assertIn(".link-mcp-venv", scaffold) self.assertIn(".link-mcp-python", scaffold) + self.assertIn("lnk.cmd", scaffold) self.assertIn("link.cmd", scaffold) + self.assertIn("Removed old Link wrapper", scaffold) self.assertIn("Link command wrapper", scaffold) self.assertIn("Get-Command py", scaffold) self.assertIn("-m venv", scaffold) @@ -80,9 +84,9 @@ def test_installers_print_mode_specific_next_steps(self): self.assertIn("link_print_next_steps()", instructions) self.assertIn('if [ "$mode" = "--project" ]; then', instructions) self.assertIn("View wiki: python3 link.py serve", instructions) - self.assertIn("View wiki: link serve", instructions) + self.assertIn("View wiki: lnk serve", instructions) self.assertIn("Print starter prompts: python3 link.py next", instructions) - self.assertIn("Print starter prompts: link next", instructions) + self.assertIn("Print starter prompts: lnk next", instructions) self.assertIn("Try in your agent:", instructions) self.assertIn("is Link ready?", instructions) self.assertIn("brief me from Link before we continue", instructions) diff --git a/tests/test_link_cli.py b/tests/test_link_cli.py index 2e4f1e0..e798d61 100644 --- a/tests/test_link_cli.py +++ b/tests/test_link_cli.py @@ -54,8 +54,8 @@ def test_init_creates_empty_wiki(self): backlinks = json.loads((target / "wiki/_backlinks.json").read_text(encoding="utf-8")) self.assertIn("backlinks", backlinks) self.assertIn("forward", backlinks) - self.assertIn("link health", out.getvalue()) - self.assertIn("link serve", out.getvalue()) + self.assertIn("lnk health", out.getvalue()) + self.assertIn("lnk serve", out.getvalue()) def test_init_preserves_existing_pages(self): tmp = Path(tempfile.mkdtemp(prefix="link-init-test-")) @@ -101,7 +101,7 @@ def test_prompts_prints_first_run_agent_prompts(self): 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 health", out.getvalue()) + self.assertIn("lnk health", out.getvalue()) def test_prompts_json_supports_project_examples(self): tmp = Path(tempfile.mkdtemp(prefix="link-prompts-test-")) @@ -130,7 +130,7 @@ def test_welcome_prints_short_first_use_path(self): self.assertIn("Link welcome:", text) self.assertIn("1. is Link ready?", text) self.assertIn("Proves: Agent can find Link", text) - self.assertIn("link health", text) + self.assertIn("lnk health", text) self.assertIn("http://127.0.0.1:3000/health", text) def test_welcome_json_supports_project_examples(self): @@ -175,7 +175,7 @@ def test_serve_reports_missing_wiki(self): self.assertEqual(code, 1) self.assertIn("Link wiki missing", out.getvalue()) - self.assertIn("link init", out.getvalue()) + self.assertIn("lnk init", out.getvalue()) def test_serve_validates_port_before_spawning_viewer(self): tmp = Path(tempfile.mkdtemp(prefix="link-serve-test-")) @@ -373,12 +373,12 @@ def test_ingest_status_reports_pending_raw_file(self): self.assertIn("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()) + self.assertIn("Run: lnk validate", out.getvalue()) 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 health", out.getvalue()) + self.assertIn("lnk health", out.getvalue()) def test_ingest_status_reports_represented_completion(self): tmp = Path(tempfile.mkdtemp(prefix="link-ingest-test-")) @@ -530,7 +530,7 @@ def test_ingest_status_reports_stale_backlinks(self): self.assertEqual(code, 0) self.assertIn("Backlinks: stale", 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()) + self.assertIn("Run: lnk rebuild-backlinks", out.getvalue()) def test_status_reports_demo_readiness(self): tmp = Path(tempfile.mkdtemp(prefix="link-status-test-")) @@ -629,7 +629,7 @@ def test_operations_reports_interrupted_write_markers(self): self.assertEqual(code, 1) self.assertIn("Link operations:", out.getvalue()) self.assertIn("remember | pending | stale", out.getvalue()) - self.assertIn("link validate", out.getvalue()) + self.assertIn("lnk validate", out.getvalue()) json_out = StringIO() with redirect_stdout(json_out): @@ -658,7 +658,7 @@ def test_health_reports_interrupted_write_markers(self): self.assertEqual(code, 1) self.assertIn("Ready: no", out.getvalue()) self.assertIn("Operations: 1 total", out.getvalue()) - self.assertIn("link operations", out.getvalue()) + self.assertIn("lnk operations", out.getvalue()) def test_status_prints_readiness_warnings(self): tmp = Path(tempfile.mkdtemp(prefix="link-status-test-")) diff --git a/tests/test_mcp_connect_core.py b/tests/test_mcp_connect_core.py index 6f644d5..5323ac4 100644 --- a/tests/test_mcp_connect_core.py +++ b/tests/test_mcp_connect_core.py @@ -23,7 +23,7 @@ def test_build_codex_preview_uses_marker_python(self): root = Path(temp) wiki = root / "wiki" wiki.mkdir() - (root / ".link-mcp-python").write_text("/tmp/link python/bin/python\n", encoding="utf-8") + (root / ".link-mcp-python").write_text("/tmp/Link Python/bin/python\n", encoding="utf-8") payload = build_mcp_connect_payload( target=root, @@ -35,7 +35,7 @@ def test_build_codex_preview_uses_marker_python(self): ) self.assertEqual(payload["agent"], "codex") - self.assertEqual(payload["python"], "/tmp/link python/bin/python") + self.assertEqual(payload["python"], "/tmp/Link Python/bin/python") self.assertIn("[mcp_servers.link]", str(payload["snippet"])) self.assertIn(str(wiki), str(payload["snippet"])) diff --git a/tests/test_mcp_contract.py b/tests/test_mcp_contract.py index 8e7179f..48cda1b 100644 --- a/tests/test_mcp_contract.py +++ b/tests/test_mcp_contract.py @@ -190,7 +190,7 @@ def test_link_operations_contract(self): self.assertEqual(payload["stale_count"], 1) self.assertEqual(payload["operations"][0]["operation"], "remember") self.assertEqual(payload["operations"][0]["description"], "Save memory") - self.assertIn("link operations", payload["next_actions"][0]["command"]) + self.assertIn("lnk operations", payload["next_actions"][0]["command"]) def test_starter_prompts_contract(self): payload = json.loads(self.server.starter_prompts(project="Client Launch")) @@ -198,7 +198,7 @@ def test_starter_prompts_contract(self): self.assertEqual(payload["project"], "client-launch") self.assertEqual(payload["prompts"][0]["prompt"], "is Link ready?") self.assertIn("this project uses Link", payload["prompts"][2]["prompt"]) - self.assertTrue(any(command.startswith("link health ") for command in payload["commands"])) + self.assertTrue(any(command.startswith("lnk health ") for command in payload["commands"])) def test_missing_wiki_message_points_to_current_setup_paths(self): previous_argv = sys.argv[:] @@ -216,7 +216,7 @@ def test_missing_wiki_message_points_to_current_setup_paths(self): self.assertEqual(cm.exception.code, 1) text = err.getvalue() self.assertIn("Wiki not found", text) - self.assertIn("link init", text) + self.assertIn("lnk init", text) self.assertIn("python3 link.py init", text) self.assertIn("integrations/*/install.sh", text) self.assertIn("--wiki /path/to/wiki", text) diff --git a/tests/test_operations_core.py b/tests/test_operations_core.py index 3705b4d..67fbfd0 100644 --- a/tests/test_operations_core.py +++ b/tests/test_operations_core.py @@ -84,7 +84,7 @@ def test_operation_report_renders_stale_marker_guidance(self): self.assertIn("remember | pending | stale", text) self.assertIn("Description: Save memory", text) self.assertIn("Touched: wiki/memories/prefer-local.md, wiki/log.md", text) - self.assertIn("link validate", text) + self.assertIn("lnk validate", text) self.assertIn(str(wiki.parent), text) self.assertIn("Result: needs attention", text) diff --git a/tests/test_prompts_core.py b/tests/test_prompts_core.py index 1310e66..940a2e9 100644 --- a/tests/test_prompts_core.py +++ b/tests/test_prompts_core.py @@ -28,8 +28,8 @@ def test_global_wiki_gets_personal_memory_prompts(self): self.assertIn("remember that I prefer local-first agent memory", prompts) self.assertIn("query Link for what you know about me", prompts) self.assertIn("propose memories from raw/", prompts) - self.assertTrue(str(payload["shortcut"]).startswith("link next ")) - self.assertTrue(any(command.startswith("link health ") for command in payload["commands"])) + self.assertTrue(str(payload["shortcut"]).startswith("lnk next ")) + self.assertTrue(any(command.startswith("lnk health ") for command in payload["commands"])) self.assertTrue(any(str(root) in command for command in payload["commands"])) def test_git_project_gets_project_memory_prompts(self): @@ -63,7 +63,7 @@ def test_welcome_payload_returns_short_proof_path(self): self.assertEqual(len(payload["steps"]), 3) self.assertEqual(payload["steps"][0]["prompt"], "is Link ready?") self.assertIn("Agent can find Link", payload["steps"][0]["proves"]) - self.assertTrue(any(command.startswith("link serve ") for command in payload["commands"])) + self.assertTrue(any(command.startswith("lnk serve ") for command in payload["commands"])) self.assertTrue(any(str(root) in command for command in payload["commands"])) self.assertIn("http://127.0.0.1:3000/health", payload["urls"]) diff --git a/tests/test_serve.py b/tests/test_serve.py index afee881..e6438be 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -298,7 +298,7 @@ def test_health_page_and_operations_api_show_interrupted_writes(self): self.assertEqual(page_status, 200) self.assertIn(b"Health", body) self.assertIn(b"Interrupted Operations", body) - self.assertIn(b"link operations", body) + self.assertIn(b"lnk operations", body) self.assertEqual(api_status, 200) self.assertEqual(payload["api_version"], serve.API_VERSION) self.assertEqual(payload["stale_count"], 1) @@ -548,7 +548,7 @@ def test_prompts_page_and_api_share_starter_prompts(self): self.assertIn("Ask Your Agent", html) self.assertIn("Local Checks", html) self.assertIn("Project examples are scoped to client-launch", html) - self.assertIn("link health", html) + self.assertIn("lnk health", html) def test_css_has_explicit_black_dark_theme(self): self.assertIn(':root[data-theme="dark"]', serve.CSS) @@ -1930,7 +1930,7 @@ def test_ingest_page_and_api_show_pending_raw(self): self.assertIn('data-copy-text="ingest raw/new-source.md into Link"', html) self.assertIn("Copy prompt", html) self.assertIn("Copy command", html) - self.assertIn('data-copy-text="link validate ', html) + self.assertIn('data-copy-text="lnk validate ', html) self.assertIn(str(wiki.parent), html) self.assertIn("ingest raw/new-source.md into Link", html) self.assertIn("open memory proposals first", html) diff --git a/tests/test_share_core.py b/tests/test_share_core.py index b56c2dc..888e93a 100644 --- a/tests/test_share_core.py +++ b/tests/test_share_core.py @@ -51,7 +51,7 @@ def test_share_resolves_exact_memory_title(self): self.assertEqual(payload["resolution"], "exact") self.assertEqual(payload["page"]["name"], "prefer-local-memory") self.assertEqual(payload["url"], "http://127.0.0.1:3456/page/prefer-local-memory") - self.assertIn("link serve", payload["serve_command_text"]) + self.assertIn("lnk serve", payload["serve_command_text"]) def test_share_resolves_path_alias_and_search(self): wiki = self.make_wiki() diff --git a/tests/test_tool_contract.py b/tests/test_tool_contract.py index 9914862..dea5ea6 100644 --- a/tests/test_tool_contract.py +++ b/tests/test_tool_contract.py @@ -33,7 +33,7 @@ def test_contract_reports_missing_mcp_docs(self): shutil.copy2(ROOT / "mcp_package/link_mcp/server.py", tmp / "mcp_package/link_mcp/server.py") (tmp / "docs").mkdir() - cli_reference = "\n".join(f"`link {command}`" for command in sorted(contract.DOCS_CLI_COMMANDS)) + cli_reference = "\n".join(f"`lnk {command}`" for command in sorted(contract.DOCS_CLI_COMMANDS)) mcp_reference = "\n".join( tool for tool in sorted(contract.EXPECTED_MCP_TOOLS) if tool != "query_link" ) diff --git a/tests/test_web_health_core.py b/tests/test_web_health_core.py index fc19e42..f865ac1 100644 --- a/tests/test_web_health_core.py +++ b/tests/test_web_health_core.py @@ -37,7 +37,7 @@ def test_render_health_page_shows_readiness_operations_and_commands(tmp_path): "next_actions": [ { "label": "inspect operation marker files before deleting them", - "command": f"link operations {tmp_path}", + "command": f"lnk operations {tmp_path}", } ], "operations": [{"operation": "remember", "description": "Save memory", "marker": "remember-1.json"}], @@ -60,8 +60,8 @@ def test_render_health_page_shows_readiness_operations_and_commands(tmp_path): assert "remember-1.json" in html assert "Operation Next Actions" in html assert str(tmp_path) in html - assert "link operations" in html - assert "link benchmark" in html + assert "lnk operations" in html + assert "lnk benchmark" in html assert "agent memory" in html @@ -93,7 +93,7 @@ def test_render_health_page_maps_ready_actions_to_targeted_commands(tmp_path): ) assert "Next Safe Action" in html - assert "link query" in html + assert "lnk query" in html assert "what should I know before continuing?" in html assert str(tmp_path) in html @@ -126,5 +126,5 @@ def test_render_health_page_targets_memory_review_command(tmp_path): ) assert "Review pending memories" in html - assert "link memory-inbox" in html + assert "lnk memory-inbox" in html assert str(tmp_path) in html diff --git a/tests/test_web_ingest_core.py b/tests/test_web_ingest_core.py index 72e8ca4..b21530c 100644 --- a/tests/test_web_ingest_core.py +++ b/tests/test_web_ingest_core.py @@ -17,9 +17,9 @@ def _page_href(name: str) -> str: def test_copy_button_escapes_text_and_label(): - html = copy_button('link ""', "") + html = copy_button('lnk ""', "") - assert 'data-copy-text="link "<raw>""' in html + assert 'data-copy-text="lnk "<raw>""' in html assert "<Copy>" in html assert "" not in html @@ -35,7 +35,7 @@ def test_render_ingest_page_shows_pending_workflow(): "state": "pending_raw", "summary": "1 raw file needs ingest.", "agent_prompt": "ingest raw/new-source.md into Link", - "commands": ["link validate"], + "commands": ["lnk validate"], "notes": ["After ingest, validate."], }, "safety": {"status": "clear", "summary": "No secret-looking values detected in raw sources.", "labels": []}, @@ -47,7 +47,7 @@ def test_render_ingest_page_shows_pending_workflow(): "memory_prompt": "propose memories from raw/new-source.md", "steps": ["Read each raw file."], "batch": [{"raw": "raw/new-source.md", "suggested_source_page": "wiki/sources/new-source.md"}], - "post_checks": ["link validate"], + "post_checks": ["lnk validate"], }, } @@ -62,7 +62,7 @@ def test_render_ingest_page_shows_pending_workflow(): assert 'Validatewaitgraph current' in html assert "Copy this into your agent chat" in html assert 'data-copy-text="ingest raw/new-source.md into Link"' in html - assert 'data-copy-text="link validate"' in html + assert 'data-copy-text="lnk validate"' in html assert "Ingest path" in html assert "Ingest pending raw sources" in html assert "wiki/sources/new-source.md" in html @@ -127,7 +127,7 @@ def test_render_ingest_page_targets_next_step_commands(): "guidance": { "state": "empty", "summary": "Link is ready, but raw/ has no source files yet.", - "commands": ["link ingest-status /tmp/link"], + "commands": ["lnk ingest-status /tmp/link"], }, "safety": {"status": "clear", "summary": "No warnings.", "labels": []}, "pending_raw": [], @@ -137,8 +137,8 @@ def test_render_ingest_page_targets_next_step_commands(): html = render_ingest_page(payload, page_href=_page_href, layout=_layout) - assert 'data-copy-text="link ingest-status /tmp/link"' in html - assert "link validate /tmp/link" in html + assert 'data-copy-text="lnk ingest-status /tmp/link"' in html + assert "lnk validate /tmp/link" in html def test_render_ingest_page_blocks_secret_raw_without_proposal_link(): diff --git a/tests/test_web_memory_core.py b/tests/test_web_memory_core.py index fc2c2c5..2c97bf2 100644 --- a/tests/test_web_memory_core.py +++ b/tests/test_web_memory_core.py @@ -32,7 +32,7 @@ def test_memory_card_escapes_content_and_renders_actions(self): "actions": [{ "label": "Review", "kind": "review", - "command": "link review-memory local-memory", + "command": "lnk review-memory local-memory", "arguments": {"identifier": "local-memory"}, }], } @@ -44,7 +44,7 @@ def test_memory_card_escapes_content_and_renders_actions(self): self.assertIn('/graph?focus=local-memory&depth=2', html) self.assertIn("Use <local> memory.", html) self.assertIn('data-memory-action="review"', html) - self.assertIn('data-copy-text="link review-memory local-memory"', html) + self.assertIn('data-copy-text="lnk review-memory local-memory"', html) self.assertNotIn("", html) def test_memory_card_escapes_generated_trust_links(self): @@ -68,7 +68,7 @@ def test_memory_section_uses_action_hints_when_record_has_no_actions(self): action_hints=lambda _record: [{ "label": "Archive", "kind": "archive", - "command": "link archive-memory agent-memory", + "command": "lnk archive-memory agent-memory", "arguments": {"identifier": "agent-memory"}, }], href="/inbox", @@ -76,7 +76,7 @@ def test_memory_section_uses_action_hints_when_record_has_no_actions(self): self.assertIn('view all', html) self.assertIn('data-memory-action="archive"', html) - self.assertIn("link archive-memory agent-memory", html) + self.assertIn("lnk archive-memory agent-memory", html) def test_capture_card_escapes_warnings_and_commands(self): html = render_capture_card({ @@ -108,14 +108,14 @@ def test_next_actions_render_commands(self): html = render_memory_next_actions([{ "label": "Review", "detail": "Open inbox.", - "command": "link memory-inbox", + "command": "lnk memory-inbox", "href": "/inbox", }]) self.assertIn('Review', html) self.assertIn("Open inbox.", html) - self.assertIn("link memory-inbox", html) - self.assertIn('data-copy-text="link memory-inbox"', html) + self.assertIn("lnk memory-inbox", html) + self.assertIn('data-copy-text="lnk memory-inbox"', html) def test_memory_dashboard_next_actions_cover_empty_ready_and_review_states(self): empty_actions = memory_dashboard_next_actions( diff --git a/tests/test_web_memory_pages_core.py b/tests/test_web_memory_pages_core.py index ab64a44..b2fcd71 100644 --- a/tests/test_web_memory_pages_core.py +++ b/tests/test_web_memory_pages_core.py @@ -86,7 +86,7 @@ def test_render_memory_dashboard_page_shows_counts_next_actions_and_sections(): "archived_count": 0, "by_type": {"preference": 2}, "by_scope": {"project": 1}, - "next_actions": [{"label": "Review", "detail": "Confirm memory", "command": "link memory-inbox"}], + "next_actions": [{"label": "Review", "detail": "Confirm memory", "command": "lnk memory-inbox"}], "review": [], "captures": [], "recent_updates": [], @@ -102,7 +102,7 @@ def test_render_memory_dashboard_page_shows_counts_next_actions_and_sections(): assert 'data-copy-text="brief me from Link for project alpha"' in html assert 'data-copy-text="audit Link memory for project alpha"' in html assert "Types: preference: 2" in html - assert "link memory-inbox" in html + assert "lnk memory-inbox" in html assert "No memories need review." in html @@ -243,7 +243,7 @@ def test_render_inbox_page_lists_review_items_and_actions(): "tldr": "Needs review.", "issues": [{"severity": "warning", "code": "pending", "message": "Needs "}], "primary_action": {"label": "Review", "description": "Confirm it"}, - "actions": [{"label": "Mark reviewed", "command": "link review-memory memory-one"}], + "actions": [{"label": "Mark reviewed", "command": "lnk review-memory memory-one"}], } ], } @@ -255,7 +255,7 @@ def test_render_inbox_page_lists_review_items_and_actions(): assert "Memory <One>" in html assert "Needs <review>" in html assert "/explain-memory?memory=memory-one" in html - assert "link review-memory memory-one" in html + assert "lnk review-memory memory-one" in html def test_render_memory_explanation_page_shows_trust_context_actions_and_body(): @@ -271,7 +271,7 @@ def test_render_memory_explanation_page_shows_trust_context_actions_and_body(): "issue_count": 1, "issues": [{"severity": "warning", "code": "pending", "message": "Needs "}], "primary_action": {"label": "Review", "description": "Confirm it"}, - "actions": [{"label": "Forget", "command": "link forget-memory prefer-reviewable-memory"}], + "actions": [{"label": "Forget", "command": "lnk forget-memory prefer-reviewable-memory"}], }, "provenance": { "source": "", @@ -290,7 +290,7 @@ def test_render_memory_explanation_page_shows_trust_context_actions_and_body(): assert "needs_review" in html assert "Needs <review>" in html assert "Next: Review" in html - assert "link forget-memory prefer-reviewable-memory" in html + assert "lnk forget-memory prefer-reviewable-memory" in html assert "/graph?focus=prefer-reviewable-memory&depth=2" in html assert "Open local graph" in html assert "agent-memory" in html @@ -325,7 +325,7 @@ def test_render_memory_wins_page_shows_local_proof_signals(): ], "prompts": ["what does Link remember about me?"], "next_actions": [ - {"label": "Use memory", "reason": "Try the value loop.", "command": "link brief current-task ."} + {"label": "Use memory", "reason": "Try the value loop.", "command": "lnk brief current-task ."} ], } @@ -336,4 +336,4 @@ def test_render_memory_wins_page_shows_local_proof_signals(): assert "Reusable <context>" in html assert "Alpha <memory>" in html assert 'data-copy-text="what does Link remember about me?"' in html - assert "link brief current-task ." in html + assert "lnk brief current-task ." in html diff --git a/tests/test_web_prompts_core.py b/tests/test_web_prompts_core.py index 827437b..4e7827c 100644 --- a/tests/test_web_prompts_core.py +++ b/tests/test_web_prompts_core.py @@ -15,9 +15,9 @@ def _layout(title: str, body: str) -> str: def test_render_prompts_page_shows_project_and_commands(): payload = { "project": "client-launch", - "shortcut": "link next /tmp/link", + "shortcut": "lnk next /tmp/link", "prompts": [{"label": "Readiness", "prompt": "is Link ready?", "when": "Before work"}], - "commands": ["link health"], + "commands": ["lnk health"], } html = render_prompts_page(payload, layout=_layout) @@ -26,22 +26,22 @@ def test_render_prompts_page_shows_project_and_commands(): assert "Project examples are scoped to client-launch" in html assert "One Command" in html assert "Use this any time you forget what to ask next." in html - assert "link next /tmp/link" in html - assert 'data-copy-text="link next /tmp/link"' in html + assert "lnk next /tmp/link" in html + assert 'data-copy-text="lnk next /tmp/link"' in html assert "Ask Your Agent" in html assert "Local Checks" in html assert "is Link ready?" in html assert 'data-copy-text="is Link ready?"' in html assert "Before work" in html - assert "link health" in html - assert 'data-copy-text="link health"' in html + assert "lnk health" in html + assert 'data-copy-text="lnk health"' in html def test_render_prompts_page_escapes_payload_fields(): payload = { "project": "", "prompts": [{"label": "

      Read next

      Start small, then make it your agent memory.

      -

      The docs are arranged by user path: try the demo, understand the model, wire MCP, then use the CLI and maintenance tools when you need them.

      +

      The docs are arranged by user path: try the demo, understand the model, choose MCP or skills, then use the CLI and maintenance tools when you need them.

      First 10 minutes

      Run the demo, add one source, save one direct memory, and verify the loop.

      Why Link?

      Understand where Link fits versus notes apps, hosted memory APIs, agent runtimes, and graph memory systems.

      @@ -227,6 +228,7 @@

      Start small, then make it your agent memory.

      Concepts

      Understand raw sources, wiki pages, memories, graph indexes, and budgeted query packets.

      MCP setup

      Install the MCP server and teach local agents how to use Link reliably.

      CLI reference

      Every local command, grouped by daily workflow and maintenance jobs.

      +

      Official skills

      Use lazy-loadable CLI workflows when MCP setup is more than you need.

      HTTP API

      Local endpoints for status, query, memory, graph, validation, and web UI actions.

      Security model

      Local-first constraints, secret scanning, backup behavior, and HTTP safety boundaries.

      Team security review

      Deployment patterns, audit exports, Git sharing, approval gates, and current limits for small teams.

      diff --git a/docs/mcp.html b/docs/mcp.html index cd7d637..085e575 100644 --- a/docs/mcp.html +++ b/docs/mcp.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute @@ -63,6 +64,10 @@

      MCP Tour

      MCP does not need serve.py The local web viewer is only for humans. MCP clients start python -m link_mcp --wiki ... over stdio and read the same Markdown wiki directly.
      +
      + Prefer no MCP? + Use the official Link skills when an agent can run local CLI commands and you want a lazy-loaded workflow instead of MCP configuration. +

      For agent builders, the stable read/write behavior is summarized in the Link memory contract.

      Agent Installers

      diff --git a/docs/memory-contract.html b/docs/memory-contract.html index 08e52f6..b06b7d1 100644 --- a/docs/memory-contract.html +++ b/docs/memory-contract.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/docs/obsidian.html b/docs/obsidian.html index 2d5f960..74c7f35 100644 --- a/docs/obsidian.html +++ b/docs/obsidian.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/docs/scale.html b/docs/scale.html index c8e233b..403edba 100644 --- a/docs/scale.html +++ b/docs/scale.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/docs/security.html b/docs/security.html index b459a2c..87f7d95 100644 --- a/docs/security.html +++ b/docs/security.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/docs/skills.html b/docs/skills.html new file mode 100644 index 0000000..8400477 --- /dev/null +++ b/docs/skills.html @@ -0,0 +1,108 @@ + + + + + + + Link - Skills + + + + + + + + + + + + + +
      +
      + No MCP required +

      Lazy-load Link through skills.

      +

      Official skills are small CLI workflows an agent can load only when needed: health, retrieval, ingest, and memory lifecycle.

      +
      +
      + +
      +
      + +
      +

      Why Skills

      +

      MCP gives Link structured tool calls, but it should not be the only path. If an agent can run local commands, it can use Link through lnk without an MCP server, background viewer, or extra client configuration. From a source checkout, the same workflows work with python3 link.py.

      +
      + lnk serve is optional +

      The web viewer is only for humans. Skills, CLI commands, and MCP tools all read the same local Markdown wiki directly.

      +
      + +

      Included Skills

      +

      The repo ships official skill folders under skills/:

      + + + + + + + + + + +
      SkillUse it forFirst command
      link-healthReadiness, interrupted writes, backups, repair, validation.lnk health
      link-retrieveBounded query packets, briefs, graph summaries, benchmarks.lnk query "..."
      link-ingestRaw source ingest, stale sources, memory proposals, post-ingest checks.lnk ingest-status
      link-memoryRemember, recall, review, explain, update, archive, restore, forget.lnk brief "..."
      + +

      Use Them

      +

      Point your agent at the skill folder it supports, or copy the relevant SKILL.md into that agent's local skill directory.

      +
      skills/link-health/SKILL.md
      +skills/link-retrieve/SKILL.md
      +skills/link-ingest/SKILL.md
      +skills/link-memory/SKILL.md
      +

      Then use natural prompts that map to the skills:

      +
      is Link ready?
      +brief me from Link before we continue
      +query Link for the release process
      +ingest raw/notes.md into Link
      +remember that I prefer short release notes
      + +

      Rules For Agents

      +
        +
      • Prefer lnk health when readiness is unclear.
      • +
      • Prefer lnk query, lnk brief, and lnk graph-summary over reading the whole wiki.
      • +
      • Use lnk ingest-status before touching raw sources.
      • +
      • Do not silently create durable memory; use lnk remember only when the user asks or approves a proposal.
      • +
      • Run lnk validate and lnk health after ingest or broad wiki edits.
      • +
      +

      Use MCP when you want structured tool calls inside an MCP client. Use skills when you want a lightweight, lazy-loaded CLI workflow.

      +
      +
      +
      + + + + diff --git a/docs/team-security.html b/docs/team-security.html index b83b9ea..e158657 100644 --- a/docs/team-security.html +++ b/docs/team-security.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/docs/troubleshooting.html b/docs/troubleshooting.html index 9c28456..f570f74 100644 --- a/docs/troubleshooting.html +++ b/docs/troubleshooting.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/docs/ui.html b/docs/ui.html index fa15e61..f090400 100644 --- a/docs/ui.html +++ b/docs/ui.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/docs/why-link.html b/docs/why-link.html index baada86..830b96c 100644 --- a/docs/why-link.html +++ b/docs/why-link.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/skills/link-health/SKILL.md b/skills/link-health/SKILL.md new file mode 100644 index 0000000..2d6640d --- /dev/null +++ b/skills/link-health/SKILL.md @@ -0,0 +1,34 @@ +--- +name: link-health +description: Use when a user wants to verify Link readiness, troubleshoot a local wiki, inspect interrupted writes, repair generated indexes, or back up Link without setting up MCP. +--- + +# Link Health + +Use the `lnk` CLI. In a source checkout, replace `lnk` with `python3 link.py`. MCP and the local web viewer are optional; `lnk serve` is only for humans to browse the wiki. + +1. Check readiness first: + ```bash + lnk health [link-root] + ``` +2. If the output mentions interrupted or stale operations, inspect them before repair: + ```bash + lnk operations [link-root] + ``` +3. Before broad repairs, migrations, or restore work, create a backup: + ```bash + lnk backup [link-root] + ``` +4. Repair only generated or structural state that Link reports as safe: + ```bash + lnk doctor --fix [link-root] + lnk rebuild-index [link-root] + lnk rebuild-backlinks [link-root] + ``` +5. Validate before saying the wiki is healthy: + ```bash + lnk validate [link-root] + lnk health [link-root] + ``` + +If the user asks whether MCP is ready, run `lnk verify-mcp [link-root]`. Do not start `lnk serve` for MCP or CLI work. diff --git a/skills/link-ingest/SKILL.md b/skills/link-ingest/SKILL.md new file mode 100644 index 0000000..977eba5 --- /dev/null +++ b/skills/link-ingest/SKILL.md @@ -0,0 +1,28 @@ +--- +name: link-ingest +description: Use when a user asks to ingest raw files into Link, refresh stale source pages, propose memories from sources, or validate source-backed wiki updates through the CLI without MCP. +--- + +# Link Ingest + +Use `lnk ingest-status` as the source of truth. In a source checkout, replace `lnk` with `python3 link.py`. The command tells you which raw files need work and which checks must run next. + +1. Inspect the ingest plan: + ```bash + lnk ingest-status [link-root] + ``` +2. If Link reports secret-looking values, unreadable files, or unsafe paths, stop and ask the user to fix or redact them. +3. Read only the pending raw files named by the ingest plan. Create or update one `wiki/sources/...` page per raw file, and update existing concept/entity/exploration/memory pages before creating thin duplicates. +4. Keep durable memory proposal-only until the user approves it: + ```bash + lnk propose-memories raw/ [link-root] + ``` +5. After writing wiki pages, rebuild generated indexes and validate: + ```bash + lnk rebuild-index [link-root] + lnk rebuild-backlinks [link-root] + lnk validate [link-root] + lnk health [link-root] + ``` + +Do not put raw source contents into chat unless needed for the current ingest task. Preserve source paths and provenance on generated pages. diff --git a/skills/link-memory/SKILL.md b/skills/link-memory/SKILL.md new file mode 100644 index 0000000..33ebca9 --- /dev/null +++ b/skills/link-memory/SKILL.md @@ -0,0 +1,37 @@ +--- +name: link-memory +description: Use when a user asks an agent to remember, recall, review, update, archive, restore, forget, or explain local Link memories through the CLI without requiring MCP. +--- + +# Link Memory + +Use explicit memory operations. In a source checkout, replace `lnk` with `python3 link.py`. Do not silently save durable memory; only remember when the user asks or approves a proposal. + +1. Prime before work: + ```bash + lnk brief "" [link-root] + ``` +2. Recall specific memory: + ```bash + lnk recall "" [link-root] + ``` +3. Save an explicit memory: + ```bash + lnk remember "" [link-root] --type note --scope user + ``` + Use `--project ` for project-scoped memory, `--visibility private|project|team` for sharing intent, `--review-after YYYY-MM-DD` for stale-risk memories, and `--expires-at YYYY-MM-DD` for temporary context. +4. Review and explain before trusting uncertain memory: + ```bash + lnk memory-inbox [link-root] + lnk explain-memory [link-root] + lnk review-memory [link-root] + ``` +5. Change lifecycle safely: + ```bash + lnk update-memory "" [link-root] + lnk archive-memory [link-root] --reason "" + lnk restore-memory [link-root] + lnk forget-memory [link-root] --confirm + ``` + +When duplicate or conflict warnings appear, prefer updating, reviewing, or archiving existing memory over creating another page. diff --git a/skills/link-retrieve/SKILL.md b/skills/link-retrieve/SKILL.md new file mode 100644 index 0000000..3daaff6 --- /dev/null +++ b/skills/link-retrieve/SKILL.md @@ -0,0 +1,32 @@ +--- +name: link-retrieve +description: Use when a user asks an agent to search, answer from, summarize, brief from, or navigate Link context through the CLI without loading the whole wiki or configuring MCP. +--- + +# Link Retrieve + +Use bounded CLI commands so the agent does not dump the whole wiki into context. In a source checkout, replace `lnk` with `python3 link.py`. + +1. If readiness is unclear, start with: + ```bash + lnk health [link-root] + ``` +2. For most questions, use a compact query packet: + ```bash + lnk query "" [link-root] --budget small + ``` + Increase to `--budget medium` or `--budget large` only when the packet says more context is needed. +3. Before longer work, prime from memory: + ```bash + lnk brief "" [link-root] + ``` +4. For graph context, stay bounded: + ```bash + lnk graph-summary "" [link-root] --limit 40 --depth 1 + ``` +5. For performance checks, use: + ```bash + lnk benchmark "" [link-root] --budget small + ``` + +Do not enumerate every page or request the full graph unless the user explicitly asks for an export or exhaustive audit. diff --git a/tests/test_official_skills.py b/tests/test_official_skills.py new file mode 100644 index 0000000..462357d --- /dev/null +++ b/tests/test_official_skills.py @@ -0,0 +1,74 @@ +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SKILLS_DIR = ROOT / "skills" + +EXPECTED_SKILLS = { + "link-health": ("lnk health", "lnk operations", "lnk backup", "lnk validate"), + "link-retrieve": ("lnk query", "lnk brief", "lnk graph-summary", "lnk benchmark"), + "link-ingest": ("lnk ingest-status", "lnk propose-memories", "lnk rebuild-index", "lnk validate"), + "link-memory": ("lnk brief", "lnk recall", "lnk remember", "lnk memory-inbox"), +} + + +def read_skill(name: str) -> str: + return (SKILLS_DIR / name / "SKILL.md").read_text(encoding="utf-8") + + +def parse_frontmatter(text: str) -> dict[str, str]: + if not text.startswith("---\n"): + return {} + _, frontmatter, _body = text.split("---", 2) + parsed: dict[str, str] = {} + for line in frontmatter.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + parsed[key.strip()] = value.strip() + return parsed + + +class OfficialSkillsTests(unittest.TestCase): + def test_expected_skills_exist_with_valid_frontmatter(self): + for name in EXPECTED_SKILLS: + with self.subTest(skill=name): + path = SKILLS_DIR / name / "SKILL.md" + self.assertTrue(path.exists(), f"missing official skill: {path}") + + frontmatter = parse_frontmatter(read_skill(name)) + self.assertEqual(frontmatter.get("name"), name) + self.assertGreaterEqual(len(frontmatter.get("description", "")), 60) + + def test_skills_are_cli_backed_and_lazy_loadable(self): + forbidden_claims = ( + "mcp is required", + "requires mcp", + "start the mcp server", + "must run `lnk serve`", + "must run lnk serve", + ) + for name, commands in EXPECTED_SKILLS.items(): + with self.subTest(skill=name): + text = read_skill(name) + lower = text.lower() + self.assertIn("lnk", text) + self.assertIn("[link-root]", text) + self.assertIn("MCP", text) + for command in commands: + self.assertIn(command, text) + for forbidden in forbidden_claims: + self.assertNotIn(forbidden, lower) + + def test_docs_and_readme_reference_official_skills(self): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + docs = (ROOT / "docs/skills.html").read_text(encoding="utf-8") + + self.assertIn("https://gowtham0992.github.io/link/skills.html", readme) + self.assertIn("Lazy-load Link through skills", docs) + for name in EXPECTED_SKILLS: + with self.subTest(skill=name): + skill_path = f"skills/{name}/SKILL.md" + self.assertIn(skill_path, readme) + self.assertIn(skill_path, docs) From c8a4b5307281aa0612310f4da7d9d7fdae76639d Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 31 May 2026 14:40:46 -0600 Subject: [PATCH 34/35] Polish docs after skills path --- README.md | 26 +++++++++++++++----------- docs/assets/site.css | 8 +++++++- docs/cli.html | 1 + docs/concepts.html | 4 ++-- docs/getting-started.html | 6 +++--- docs/index.html | 31 +++++++++++++++++-------------- docs/memory-contract.html | 2 +- docs/team-security.html | 4 ++-- docs/ui.html | 2 +- docs/why-link.html | 4 ++-- 10 files changed, 51 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index f0862af..ae5c931 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

      Link gives Codex, Claude, Cursor, Kiro, VS Code, Copilot, Antigravity, and - other MCP clients the same source-backed memory, stored locally as Markdown. + other local agents the same source-backed memory, stored locally as Markdown.

      @@ -33,8 +33,9 @@ Link is an open-source memory layer for local AI agents. Raw sources become an inspectable Markdown wiki. Explicit "remember this" requests become reviewable -memories. Agents retrieve compact, source-backed context through MCP without -dumping the whole wiki into a chat window. +memories. Agents retrieve compact, source-backed context through the CLI, MCP, +official skills, or the local viewer without dumping the whole wiki into a chat +window. The wiki is the storage layer. The product is durable memory that stays on your machine, remains readable in plain files, and can be shared across multiple @@ -47,7 +48,7 @@ Link gives agents four simple moves: 1. **Capture** notes, transcripts, docs, screenshots, and project context in `raw/`. 2. **Structure** source-backed pages under `wiki/`. 3. **Remember** explicit preferences, decisions, facts, and project context as reviewable memory. -4. **Retrieve** compact query packets through CLI, MCP, or the local web viewer. +4. **Retrieve** compact query packets through the CLI, MCP, official skills, or the local web viewer. Most agent sessions start from zero. You re-explain preferences, repo decisions, project constraints, and why something matters. Link turns that repeated context @@ -81,7 +82,7 @@ lnk serve link-demo The installed command is `lnk` because `link` is already a POSIX/macOS system utility. From a source checkout, use `python3 link.py ...` instead. -Windows or source checkout: +Windows PowerShell: ```powershell git clone https://github.com/gowtham0992/link.git @@ -91,7 +92,7 @@ py link.py next link-demo py link.py serve link-demo ``` -Or from source: +Source checkout on macOS/Linux: ```bash git clone https://github.com/gowtham0992/link.git @@ -310,10 +311,10 @@ Under the hood, Link separates source-backed knowledge from durable agent memory 1. Drop raw notes, transcripts, articles, and project context into `raw/`. 2. Agents compile those sources into inspectable pages under `wiki/`. 3. Explicit "remember" requests become reviewable memory pages. -4. Queries retrieve compact MCP context from both the wiki and memory layer. +4. Queries retrieve compact agent context from both the wiki and memory layer.

      - Link architecture: raw sources become wiki knowledge, explicit remembers become reviewed memory, and agents retrieve compact MCP context + Link architecture: raw sources become wiki knowledge, explicit remembers become reviewed memory, and agents retrieve compact context

      The storage model is plain and inspectable: @@ -322,7 +323,7 @@ The storage model is plain and inspectable: |-------|------------------| | `raw/` | Original notes, transcripts, articles, PDFs, screenshots, and project files. | | `wiki/` | Source-backed pages, concepts, entities, explorations, comparisons, and memories. | -| MCP tools | Compact packets agents can use without dumping the whole wiki into context. | +| Agent interfaces | CLI, skills, MCP, and local viewer paths that avoid dumping the whole wiki into context. | If a raw file was already ingested and later edited, `lnk ingest-status` marks it as stale and tells your agent to refresh the existing source page instead of @@ -330,6 +331,9 @@ creating a duplicate. ## What Agents Get +When an agent uses Link through MCP, these are the stable tools it receives. +CLI and skill workflows call the same core behavior through `lnk`. + - `query_link`: an answer-ready packet with relevant memories, pages, graph neighborhood, reasons for selection, budget limits, and follow-up actions. - `memory_brief`: a compact pre-work brief with user/project preferences, @@ -413,7 +417,7 @@ lnk snapshot ~/link --output personal-snapshot --include-memories --include-priv ## Agent Contract -Agents should use Link in this order: +For MCP clients, agents should use Link in this order: 1. `link_status` to check readiness and safe next actions. 2. `starter_prompts` when the user asks what to try first. @@ -439,7 +443,7 @@ Link itself is local-first: private key blocks are detected in raw sources, captures, and release hygiene checks. `lnk validate` and `lnk doctor` also fail if secret-looking values are found inside wiki pages before they can be served through the local UI or - returned through MCP context. + returned through agent context. - The local web server binds to `127.0.0.1` and is not meant to be exposed to the internet without additional auth. diff --git a/docs/assets/site.css b/docs/assets/site.css index 43f9272..5c8fbe5 100644 --- a/docs/assets/site.css +++ b/docs/assets/site.css @@ -263,6 +263,7 @@ section:nth-child(2n), .feature, .panel { + min-width: 0; min-height: 198px; padding: 20px; border: 3px solid var(--border); @@ -297,6 +298,7 @@ section:nth-child(2n), } .proof-column { + min-width: 0; padding: 22px; border: 3px solid var(--border); background: var(--paper-2); @@ -336,6 +338,7 @@ section:nth-child(2n), } .media-card { + min-width: 0; padding: 12px; border: 3px solid var(--border); background: var(--paper-2); @@ -369,6 +372,7 @@ section:nth-child(2n), } .screenshot-card { + min-width: 0; margin: 0; padding: 12px; border: 3px solid var(--border); @@ -378,6 +382,7 @@ section:nth-child(2n), } .architecture-card { + min-width: 0; margin: 34px 0 0; padding: 12px; border: 3px solid var(--border); @@ -418,6 +423,7 @@ figcaption { pre { position: relative; + max-width: 100%; overflow-x: auto; margin: 0; padding: 20px; @@ -610,7 +616,7 @@ footer a { .doc-table, .choice-table, .choice-table div { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } .toc { diff --git a/docs/cli.html b/docs/cli.html index 9211565..108ed75 100644 --- a/docs/cli.html +++ b/docs/cli.html @@ -27,6 +27,7 @@ MCP Contract CLI + Skills API Security Contribute diff --git a/docs/concepts.html b/docs/concepts.html index ae47992..f2c33ca 100644 --- a/docs/concepts.html +++ b/docs/concepts.html @@ -61,11 +61,11 @@

      Storage Layers

      -> direct memories -> review/update/archive
      Link architecture: raw sources, structured wiki, reviewed memory, and MCP retrieval -
      The wiki is the storage layer. Reviewed memory and MCP context are the product surface agents use.
      +
      The wiki is the storage layer. Reviewed memory and compact context are the product surfaces agents use.
      One wiki, three independent surfaces - The web UI, CLI, and MCP server are separate ways to use the same local files. Closing the viewer does not stop CLI commands or MCP recall. + The web UI, CLI, official skills, and MCP server are separate ways to use the same local files. Closing the viewer does not stop CLI commands, skills, or MCP recall.
      Public repo versus local memory diff --git a/docs/getting-started.html b/docs/getting-started.html index aebc237..0dc945a 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -39,7 +39,7 @@
      First 10 minutes

      Turn one local note into agent memory.

      -

      Start with the demo, add one real source, save one explicit memory, then ask an MCP-enabled agent to query Link.

      +

      Start with the demo, add one real source, save one explicit memory, then ask an agent to query Link through MCP, skills, or the CLI.

      @@ -81,7 +81,7 @@

      1. Run The Demo

      Viewer is optional - lnk serve is only for browsing Link in a local web UI. CLI commands and MCP-enabled agents work without it because they read the same local wiki/ files directly. + lnk serve is only for browsing Link in a local web UI. CLI commands, official skills, and MCP-enabled agents work without it because they read the same local wiki/ files directly.

      The demo includes one pending memory intentionally, so the review inbox and explain-memory workflow are visible. Run lnk review-memory prefer-local-personal-memory link-demo if you want memory audit to be fully clear.

      Open http://127.0.0.1:3000, then inspect /brief, /memory, /ingest, /graph, and /health. Open more for prompts, proposal review, audit, captures, profile, log, and all pages. Link accepts localhost too, but the numeric loopback address avoids slow IPv6 fallback in some Safari setups.

      @@ -161,7 +161,7 @@

      6. Verify The Loop

      lnk memory-audit lnk operations lnk verify-mcp
      -

      lnk verify-mcp should report Result: ready. Then ask your MCP-enabled agent:

      +

      lnk verify-mcp should report Result: ready when you use MCP. Then ask your agent:

      query Link for first Link memory

      If the answer comes from Link, local agent memory is working.

      diff --git a/docs/index.html b/docs/index.html index a01193b..3d325d6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -39,13 +39,15 @@
      Local agent memory

      Link gives every agent the same memory.

      -

      Source-backed Markdown memory for Codex, Claude, Cursor, Kiro, VS Code, Copilot, and any MCP client. Local files. Inspectable sources. Budgeted context.

      +

      Source-backed Markdown memory for Codex, Claude, Cursor, Kiro, VS Code, Copilot, Antigravity, and local agents. Local files. Inspectable sources. Budgeted context.

      • raw/
      • wiki/
      • memories/
      • graph
      • +
      • CLI
      • MCP
      • +
      • skills
      Try Link @@ -64,7 +66,7 @@

      Not another notes app. A local memory layer agents can actually use.

      Personal memory

      Preferences, decisions, facts, and project context stay durable across agent sessions.

      Source-backed wiki

      Raw sources compile into Markdown pages with citations, backlinks, and reviewable provenance.

      -

      MCP-native recall

      One local memory works across MCP-capable tools through the same query, brief, graph, and memory lifecycle tools.

      +

      Agent-ready recall

      One local memory works across CLI, skills, MCP-capable tools, and the local viewer through the same query, brief, graph, and memory lifecycle paths.

      Budgeted context

      Smart query packets return the right memory, pages, graph neighborhood, and follow-up actions without flooding tokens.

      Measured scale

      Health and benchmark commands show cache reuse, search backend, graph shape, and bounded payload behavior as a wiki grows.

      Private by default

      No hosted backend, no telemetry, no cloud lock-in. Your memory stays on disk as plain Markdown.

      @@ -78,9 +80,9 @@

      Not another notes app. A local memory layer agents can actually use.

      How it works

      A local memory pipeline agents can trust.

      -

      Raw sources become structured wiki pages. Explicit remembers become reviewed memory. Agents retrieve compact MCP context packets instead of reading your whole folder.

      +

      Raw sources become structured wiki pages. Explicit remembers become reviewed memory. Agents retrieve compact context packets instead of reading your whole folder.

      - Link architecture: capture sources, structure wiki knowledge, review memory, and retrieve compact MCP context + Link architecture: capture sources, structure wiki knowledge, review memory, and retrieve compact context
      Link keeps the knowledge base plain and local while giving agents a predictable recall path.
      @@ -129,34 +131,35 @@

      Run a finished memory wiki locally.

      Troubleshooting
      -
      # macOS
      +        
      # macOS/Homebrew proof loop
       brew install gowtham0992/link/link
       lnk demo
       lnk next link-demo
       lnk serve link-demo
      +lnk query "why does Link help agents?" link-demo --budget small
      +lnk brief "working on agent memory" link-demo
      +lnk benchmark "agent memory" link-demo
       
      -# or from source
      +# source checkout proof loop
       git clone https://github.com/gowtham0992/link.git
       cd link
       python3 link.py demo
       python3 link.py next link-demo
       python3 link.py serve link-demo
      -
      -# then try
      -lnk query "why does Link help agents?" link-demo --budget small
      -lnk brief "working on agent memory" link-demo
      -lnk benchmark "agent memory" link-demo
      +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 benchmark "agent memory" link-demo
      -

      Three surfaces

      +

      Access paths

      Review it, script it, or let an agent call it.

      -

      The web UI, CLI, and MCP server all operate on the same local Markdown wiki. Read it like a local document, script it from a terminal, or let an agent query the same memory through MCP.

      +

      The web UI, CLI, official skills, and MCP server all operate on the same local Markdown wiki. Read it like a local document, script it from a terminal, lazy-load a skill, or let an MCP client query the same memory.

      No background web server required - lnk serve only starts the human web viewer. The CLI and MCP server read the same local files directly, so agents can query Link when the viewer is closed. + lnk serve only starts the human web viewer. The CLI, skills, and MCP server read the same local files directly, so agents can query Link when the viewer is closed.
      diff --git a/docs/memory-contract.html b/docs/memory-contract.html index b06b7d1..d889b9e 100644 --- a/docs/memory-contract.html +++ b/docs/memory-contract.html @@ -61,7 +61,7 @@

      Contract Promise

      Readable storage

      Raw source notes live in raw/. Structured pages and durable memories live in wiki/.

      Bounded recall

      Agents ask for a task-sized packet instead of dumping the whole wiki into context.

      Reviewable writes

      Durable memory writes are explicit, duplicate-checked, conflict-checked, and logged.

      -

      Local trust

      The CLI, HTTP viewer, and MCP server read the same local files. serve.py is not required for MCP.

      +

      Local trust

      The CLI, official skills, HTTP viewer, and MCP server read the same local files. serve.py is not required for recall.

      Recommended Agent Loop

      diff --git a/docs/team-security.html b/docs/team-security.html index e158657..006be3f 100644 --- a/docs/team-security.html +++ b/docs/team-security.html @@ -57,10 +57,10 @@

      Local agent memory without a new cloud data boundary.

      Deployment Model

      -

      Link is designed as a local personal or repo-local memory layer. Each developer runs the CLI and MCP server on their own machine. The web viewer is optional and only serves the local UI.

      +

      Link is designed as a local personal or repo-local memory layer. Each developer runs the CLI, optional skills, and MCP server on their own machine. The web viewer is optional and only serves the local UI.

      No server dependency - lnk serve is only the human web viewer. CLI and MCP access work directly against local Markdown files when the viewer is closed. + lnk serve is only the human web viewer. CLI, skills, and MCP access work directly against local Markdown files when the viewer is closed.
      brew install gowtham0992/link/link
       lnk init ~/link
      diff --git a/docs/ui.html b/docs/ui.html
      index f090400..eabe69a 100644
      --- a/docs/ui.html
      +++ b/docs/ui.html
      @@ -93,7 +93,7 @@ 

      Start It

      # from a source checkout python3 link.py serve link-demo

      The server binds to 127.0.0.1 and is intended for local use. Do not expose it to the internet without adding your own authentication layer.

      -

      This viewer is optional. CLI commands and MCP clients keep working after you close the browser or stop serve.py.

      +

      This viewer is optional. CLI commands, official skills, and MCP clients keep working after you close the browser or stop serve.py.

      diff --git a/docs/why-link.html b/docs/why-link.html index 830b96c..9d28d8e 100644 --- a/docs/why-link.html +++ b/docs/why-link.html @@ -57,7 +57,7 @@

      Link is not a notes app. It is local memory for agents.

      Best Fit

      Use Link when you want one local memory layer that multiple agents can share. It is strongest for developer and power-user workflows where privacy, provenance, and inspectable files matter.

      -

      Personal agent memory

      Preferences, decisions, project conventions, and active context that should survive between Codex, Kiro, Claude, Cursor, and other MCP clients.

      +

      Personal agent memory

      Preferences, decisions, project conventions, and active context that should survive between Codex, Kiro, Claude, Cursor, Antigravity, and other local agents.

      Source-backed knowledge

      Raw notes, transcripts, release notes, articles, and project files that should become cited Markdown pages instead of hidden context.

      Inspectable retrieval

      Smart query packets with reasons, sources, graph links, budgets, and follow-up actions instead of full-folder dumps.

      Local ownership

      Plain Markdown and JSON indexes on your machine. No hosted backend, telemetry, or required cloud account.

      @@ -67,7 +67,7 @@

      The Loop Link Changes

      Link is useful when it changes the start and end of real agent sessions.

      MomentWithout LinkWith Link
      -
      Starting workYou retype project state and preferences.The agent calls memory_brief or query_link for compact local context.
      +
      Starting workYou retype project state and preferences.The agent asks for a brief or calls memory_brief/query_link for compact local context.
      Switching agentsEach tool has its own memory gap.Codex, Claude, Cursor, Kiro, VS Code, and Antigravity can point at the same wiki.
      Saving a decisionThe note disappears into chat history.An explicit remember becomes a reviewable Markdown memory.
      Trusting memoryYou cannot see why the agent knows something.You can inspect sources, graph links, review state, and lifecycle history.
      From 3edf13b3401f9964b8dcd5df9c0fdbdb1efc0cea Mon Sep 17 00:00:00 2001 From: Gowtham Date: Sun, 14 Jun 2026 10:11:36 -0600 Subject: [PATCH 35/35] Prepare 1.4.0 release --- CHANGELOG.md | 31 ++++++++++++++++++------------- mcp_package/link_core/version.py | 2 +- mcp_package/link_mcp/__init__.py | 2 +- mcp_package/pyproject.toml | 2 +- mcp_package/server.json | 4 ++-- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac7fa1..873ef0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,27 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ## [Unreleased] +## [1.4.0] - 2026-06-14 + ### Added -- Added `link try` as a one-command demo proof loop that creates the demo, checks readiness, runs query/brief examples, and prints first agent prompts. -- Added `link connect ` to preview or write MCP client config for Codex, Kiro, Claude Code, Cursor, Antigravity, VS Code, and Copilot. +- Added official CLI skills under `skills/` so agents can lazy-load Link workflows without MCP setup. +- Added `lnk try` as a one-command demo proof loop that creates the demo, checks readiness, runs query/brief examples, and prints first agent prompts. +- Added `lnk connect ` to preview or write MCP client config for Codex, Kiro, Claude Code, Cursor, Antigravity, VS Code, and Copilot. +- Added Windows PowerShell installers for Codex, Kiro, Claude Code, Cursor, Antigravity, VS Code, and Copilot. - Added optional `review_after` dates for durable memories so time-sensitive context can automatically return to the memory inbox for re-checking. - Added optional `expires_at` dates for durable memories so temporary context automatically leaves default recall after expiry. -- Added `link import-obsidian ` to copy Obsidian Markdown notes into `raw/obsidian/` with secret scanning before the normal ingest workflow. -- Added `link compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review. -- Added `link restore-backup` to preview and confirm local backup restores with unsafe-tar checks, raw restore opt-in, and pre-restore safety backups. -- Added `link team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically. -- Added `link share ` to print a local viewer permalink and agent prompt for a specific Link page. -- Added `link snapshot` to export a static, read-only HTML snapshot for demos or reviews while excluding raw sources, captures, live state, and memory pages by default. +- Added `lnk import-obsidian ` to copy Obsidian Markdown notes into `raw/obsidian/` with secret scanning before the normal ingest workflow. +- Added `lnk compliance-export` for redacted readiness, validation, memory-review, operation, and log exports for team or security review. +- Added `lnk restore-backup` to preview and confirm local backup restores with unsafe-tar checks, raw restore opt-in, and pre-restore safety backups. +- Added `lnk team-sync` to print a safe Git sharing plan for reviewed team memory without pushing private raw sources automatically. +- Added `lnk share ` to print a local viewer permalink and agent prompt for a specific Link page. +- Added `lnk snapshot` to export a static, read-only HTML snapshot for demos or reviews while excluding raw sources, captures, live state, and memory pages by default. - Added memory `visibility` metadata (`private`, `project`, or `team`) so team sharing can rely on explicit user intent instead of inferring privacy from scope alone. -- Added `link set-memory-visibility` and MCP `set_memory_visibility` so existing memories can move between private, project, and team sharing intent after explicit user approval. -- Added `link memory-log`, MCP `memory_log`, `/memory-log`, and `/api/memory-log` for recent memory lifecycle changes without exposing raw source or memory bodies. +- Added `lnk set-memory-visibility` and MCP `set_memory_visibility` so existing memories can move between private, project, and team sharing intent after explicit user approval. +- Added `lnk memory-log`, MCP `memory_log`, `/memory-log`, and `/api/memory-log` for recent memory lifecycle changes without exposing raw source or memory bodies. - Added privacy-safe memory-log change summaries so review, status, and visibility transitions are visible without exposing memory bodies. -- Added `link wins`, MCP `memory_wins`, `/wins`, and `/api/wins` for local, non-telemetry proof signals about what Link memory is carrying. +- Added `lnk wins`, MCP `memory_wins`, `/wins`, and `/api/wins` for local, non-telemetry proof signals about what Link memory is carrying. - Added a team security review docs page covering local deployment, data boundaries, memory approval gates, Git sharing, audit exports, and current limits. - Added a memory contract docs page that explains the stable MCP agent loop, tool groups, write rules, budget behavior, and sharing semantics. - Added an integration maintainer checklist covering installer invariants, new-agent steps, PowerShell parity, and validation commands. @@ -34,8 +38,9 @@ Release sections use `MAJOR.MINOR.PATCH` versions that match `link-mcp` on PyPI ### Changed - Broadened local secret detection for common modern provider tokens and credentials before capture, ingest, Obsidian import, and doctor scans. -- Tightened `link team-sync` readiness so unreviewed memories or active `visibility: private` memories block "ready" status before Git sharing. -- Tightened `link snapshot --include-memories` so private memories stay excluded unless `--include-private-memories` is explicitly passed. +- Changed the installed CLI command from `link` to `lnk` to avoid the POSIX/macOS `link` utility collision while preserving source-checkout `python3 link.py ...` usage. +- Tightened `lnk team-sync` readiness so unreviewed memories or active `visibility: private` memories block "ready" status before Git sharing. +- Tightened `lnk snapshot --include-memories` so private memories stay excluded unless `--include-private-memories` is explicitly passed. - Broadened Windows CI from a small portability subset to most non-installer/non-server tests. - Clarified that the Homebrew formula lives in the separate `gowtham0992/homebrew-link` tap. - Tightened security reporting guidance to prefer private maintainer contact before public GitHub issues. diff --git a/mcp_package/link_core/version.py b/mcp_package/link_core/version.py index 7c2e305..2b5b2d2 100644 --- a/mcp_package/link_core/version.py +++ b/mcp_package/link_core/version.py @@ -1,4 +1,4 @@ """Shared Link release version.""" from __future__ import annotations -LINK_VERSION = "1.3.0" +LINK_VERSION = "1.4.0" diff --git a/mcp_package/link_mcp/__init__.py b/mcp_package/link_mcp/__init__.py index f3ba7ec..97e5965 100644 --- a/mcp_package/link_mcp/__init__.py +++ b/mcp_package/link_mcp/__init__.py @@ -1,2 +1,2 @@ """Link MCP Server — personal knowledge wiki as MCP tools.""" -__version__ = "1.3.0" +__version__ = "1.4.0" diff --git a/mcp_package/pyproject.toml b/mcp_package/pyproject.toml index 1b5da91..30fc96a 100644 --- a/mcp_package/pyproject.toml +++ b/mcp_package/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "link-mcp" -version = "1.3.0" +version = "1.4.0" description = "MCP server for Link local agent memory — remember, recall, search, context, and graph traversal" readme = "README.md" license = { text = "MIT" } diff --git a/mcp_package/server.json b/mcp_package/server.json index 30032df..3c62355 100644 --- a/mcp_package/server.json +++ b/mcp_package/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/gowtham0992/link", "source": "github" }, - "version": "1.3.0", + "version": "1.4.0", "packages": [ { "registryType": "pypi", "identifier": "link-mcp", - "version": "1.3.0", + "version": "1.4.0", "transport": { "type": "stdio" }