Skip to content

Commit dbe1e11

Browse files
Paul Kyleclaude
andcommitted
feat: lint command, Obsidian docs, palinode_lint MCP tool (#15 tools)
- palinode lint: scans for orphaned files, stale active files (>90d), missing frontmatter, and potential contradictions - POST /lint API endpoint - palinode_lint MCP tool - docs/OBSIDIAN-SETUP.md: browse memory in Obsidian - tests/test_lint.py: full coverage of lint logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 16f4f06 commit dbe1e11

7 files changed

Lines changed: 297 additions & 22 deletions

File tree

docs/OBSIDIAN-SETUP.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Using Palinode with Obsidian
2+
3+
Because Palinode stores all memories as standard markdown files, you can use [Obsidian](https://obsidian.md) to visually browse, edit, and link your agent's memories.
4+
5+
## Setup
6+
7+
1. Open Obsidian
8+
2. Click "Open folder as vault"
9+
3. Select your `PALINODE_DIR` (e.g., `~/.palinode`)
10+
11+
## Why This Rocks
12+
13+
- **Visual Graph:** See how your agent connects people, projects, and ideas (frontmatter `entities` arrays act as tags/links if you configure Obsidian's Dataview)
14+
- **Manual Curation:** If the agent gets a detail wrong, just type to fix it. The file watcher will pick up your changes within 2 seconds.
15+
- **GrueBrain Rationalization:** If you use GrueBrain, you can symlink specific subdirectories (like `projects/` or `daily/`) into your main GrueBrain vault.

palinode/api/server.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,13 @@ def entities_list_api() -> list[dict[str, Any]]:
603603
return results
604604

605605

606+
@app.post("/lint")
607+
def lint_api() -> dict[str, Any]:
608+
"""Scan memory and report orphans, stale files, and contradictions."""
609+
from palinode.core.lint import run_lint_pass
610+
return run_lint_pass()
611+
612+
606613
@app.get("/history/{file_path:path}")
607614
def history_api(file_path: str, limit: int = 10) -> dict[str, Any]:
608615
"""Get the change history for a memory file via git log.

palinode/cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from palinode.cli.session_end import session_end
1515
from palinode.cli.read import read
1616
from palinode.cli.list import list_cmd
17+
from palinode.cli.lint import lint
1718

1819
@click.group()
1920
def main():
@@ -47,6 +48,7 @@ def main():
4748
main.add_command(entities)
4849
main.add_command(read)
4950
main.add_command(list_cmd, name="list")
51+
main.add_command(lint)
5052

5153
# Session
5254
main.add_command(session_end)

palinode/cli/lint.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import click
2+
import json
3+
import httpx
4+
from rich.console import Console
5+
6+
from palinode.core.config import config
7+
8+
console = Console()
9+
10+
@click.command()
11+
@click.option("--format", "fmt", type=click.Choice(["json", "text"]), default="text", help="Output format")
12+
def lint(fmt):
13+
"""Scan memory and report orphans, stale files, and contradictions."""
14+
api_url = f"http://localhost:{config.services.api.port}/lint"
15+
16+
try:
17+
resp = httpx.post(api_url, timeout=30.0)
18+
if resp.status_code != 200:
19+
console.print(f"[red]Error: API returned {resp.status_code}[/red]")
20+
return
21+
data = resp.json()
22+
except httpx.RequestError:
23+
# Fallback to local import if API is down
24+
from palinode.core.lint import run_lint_pass
25+
data = run_lint_pass()
26+
27+
if fmt == "json":
28+
console.print(json.dumps(data, indent=2))
29+
return
30+
31+
console.print(f"\n[bold green]Palinode Memory Lint Report[/bold green]\n")
32+
33+
if data["missing_fields"]:
34+
console.print(f"[bold yellow]Missing Frontmatter ({len(data['missing_fields'])})[/bold yellow]")
35+
for mf in data["missing_fields"]:
36+
console.print(f" - {mf['file']}: missing {', '.join(mf['missing'])}")
37+
else:
38+
console.print("[green]✓ No files missing frontmatter[/green]")
39+
40+
console.print("")
41+
42+
if data["orphaned_files"]:
43+
console.print(f"[bold yellow]Orphaned Files ({len(data['orphaned_files'])})[/bold yellow]")
44+
for of in data["orphaned_files"]:
45+
console.print(f" - {of}")
46+
else:
47+
console.print("[green]✓ No orphaned files[/green]")
48+
49+
console.print("")
50+
51+
if data["stale_files"]:
52+
console.print(f"[bold yellow]Stale Active Files ({len(data['stale_files'])})[/bold yellow]")
53+
for sf in data["stale_files"]:
54+
console.print(f" - {sf['file']} ({sf['days_old']} days old)")
55+
else:
56+
console.print("[green]✓ No stale active files (>90 days)[/green]")
57+
58+
console.print("")
59+
60+
if data["contradictions"]:
61+
console.print(f"[bold yellow]Potential Contradictions ({len(data['contradictions'])})[/bold yellow]")
62+
for ct in data["contradictions"]:
63+
console.print(f" - {ct['entity']}: {ct['issue']}")
64+
else:
65+
console.print("[green]✓ No contradictions detected[/green]")
66+
67+
console.print("")

palinode/core/lint.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import glob
5+
from datetime import datetime, timezone
6+
from typing import Any
7+
8+
from palinode.core.config import config
9+
from palinode.core import parser
10+
11+
def run_lint_pass() -> dict[str, Any]:
12+
"""Scan PALINODE_DIR for memory health issues.
13+
14+
Checks for:
15+
- Orphaned files (no entities, no references from other files)
16+
- Stale files (not updated in 90+ days, still marked status: active)
17+
- Missing fields (missing 'type', 'id', 'category')
18+
- Contradictions (potential contradictions, heuristic check)
19+
"""
20+
base_dir = getattr(config, 'memory_dir', config.palinode_dir)
21+
pattern = os.path.join(base_dir, "**/*.md")
22+
23+
orphaned_files = []
24+
stale_files = []
25+
missing_fields = []
26+
contradictions = [] # Heuristic placeholder
27+
28+
now = datetime.now(timezone.utc)
29+
30+
entity_references: dict[str, int] = {}
31+
all_files = []
32+
33+
skip_dirs = {"archive", "logs", ".obsidian"}
34+
35+
for filepath in glob.glob(pattern, recursive=True):
36+
rel_path = os.path.relpath(filepath, base_dir)
37+
parts = rel_path.split(os.sep)
38+
if parts[0] in skip_dirs:
39+
continue
40+
41+
try:
42+
with open(filepath, "r") as f:
43+
content = f.read()
44+
metadata, _ = parser.parse_markdown(content)
45+
46+
entities = metadata.get("entities", [])
47+
for e in entities:
48+
entity_references[e] = entity_references.get(e, 0) + 1
49+
50+
all_files.append({
51+
"path": rel_path,
52+
"metadata": metadata,
53+
})
54+
except Exception:
55+
pass
56+
57+
for f in all_files:
58+
path = f["path"]
59+
meta = f["metadata"]
60+
61+
# 1. Missing fields
62+
missing = []
63+
if not meta.get("id"): missing.append("id")
64+
if not meta.get("type"): missing.append("type")
65+
if not meta.get("category"): missing.append("category")
66+
if missing:
67+
missing_fields.append({"file": path, "missing": missing})
68+
69+
# 2. Orphans
70+
category = meta.get("category", "")
71+
if category and not path.startswith("daily/"):
72+
slug = path.split(os.sep)[-1].replace(".md", "")
73+
# Removing any layer suffixes like -status or -history
74+
if slug.endswith("-status"): slug = slug[:-7]
75+
if slug.endswith("-history"): slug = slug[:-8]
76+
77+
own_entity_ref = f"{category}/{slug}"
78+
has_entities = len(meta.get("entities", [])) > 0
79+
is_referenced = entity_references.get(own_entity_ref, 0) > 0
80+
81+
# An orphan has NO entities AND is not referenced by anything else
82+
if not has_entities and not is_referenced:
83+
orphaned_files.append(path)
84+
85+
# 3. Stale
86+
if meta.get("status") == "active":
87+
last_updated = meta.get("last_updated") or meta.get("created_at")
88+
if last_updated:
89+
try:
90+
if isinstance(last_updated, str):
91+
dt = datetime.fromisoformat(last_updated.replace('Z', '+00:00'))
92+
else:
93+
dt = last_updated
94+
if dt.tzinfo is None:
95+
dt = dt.replace(tzinfo=timezone.utc)
96+
97+
days_old = (now - dt).days
98+
if days_old > 90:
99+
stale_files.append({"file": path, "days_old": days_old})
100+
except Exception:
101+
pass
102+
103+
# 4. Contradictions heuristics
104+
# Simple check: Any entity that has multiple active files
105+
file_statuses = {}
106+
for f in all_files:
107+
cat = f["metadata"].get("category", "")
108+
if not cat or f["path"].startswith("daily/"): continue
109+
slug = f["path"].split(os.sep)[-1].replace(".md", "")
110+
if slug.endswith("-status"): slug = slug[:-7]
111+
if slug.endswith("-history"): slug = slug[:-8]
112+
ent = f"{cat}/{slug}"
113+
114+
status = f["metadata"].get("status", "active")
115+
if status == "active":
116+
file_statuses[ent] = file_statuses.get(ent, 0) + 1
117+
if file_statuses[ent] > 1:
118+
contradictions.append({
119+
"entity": ent,
120+
"issue": "Multiple 'active' files detected for the same entity."
121+
})
122+
123+
# Deduplicate contradictions
124+
unique_contradictions = [dict(t) for t in {tuple(d.items()) for d in contradictions}]
125+
126+
return {
127+
"orphaned_files": orphaned_files,
128+
"stale_files": stale_files,
129+
"missing_fields": missing_fields,
130+
"contradictions": unique_contradictions
131+
}

palinode/mcp.py

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"mcpServers": {
1616
"palinode": {
1717
"command": "ssh",
18-
"args": ["user@your-server",
18+
"args": ["user@your-server.your-tailscale.ts.net",
1919
"cd /path/to/palinode && venv/bin/python -m palinode.mcp"]
2020
}
2121
}
@@ -320,14 +320,6 @@ async def list_tools() -> list[types.Tool]:
320320
"properties": {},
321321
},
322322
),
323-
types.Tool(
324-
name="palinode_lint",
325-
description="Scan memory files and report health issues (orphaned files, stale files, missing fields).",
326-
inputSchema={
327-
"type": "object",
328-
"properties": {},
329-
},
330-
),
331323
types.Tool(
332324
name="palinode_diff",
333325
description=(
@@ -491,6 +483,17 @@ async def list_tools() -> list[types.Tool]:
491483
"required": ["summary"],
492484
},
493485
),
486+
types.Tool(
487+
name="palinode_lint",
488+
description=(
489+
"Scan memory for health issues: orphaned files, stale active files (>90 days), "
490+
"missing frontmatter fields, and potential contradictions. Returns a report without modifying files."
491+
),
492+
inputSchema={
493+
"type": "object",
494+
"properties": {},
495+
},
496+
),
494497
]
495498

496499

@@ -701,19 +704,6 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextCont
701704
]
702705
return [types.TextContent(type="text", text="\n".join(lines))]
703706

704-
elif name == "palinode_lint":
705-
import json
706-
import httpx
707-
api_port = config.services.api.port
708-
try:
709-
resp = httpx.post(f"http://localhost:{api_port}/lint", timeout=30.0)
710-
if resp.status_code == 200:
711-
return [types.TextContent(type="text", text=json.dumps(resp.json(), indent=2))]
712-
return [types.TextContent(type="text", text=f"Error running lint: {resp.text}")]
713-
except httpx.RequestError:
714-
from palinode.core.lint import run_lint_pass
715-
return [types.TextContent(type="text", text=json.dumps(run_lint_pass(), indent=2))]
716-
717707
elif name == "palinode_diff":
718708
from palinode.core import git_tools
719709
days = int(arguments.get("days", 7))
@@ -826,6 +816,13 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextCont
826816
text=f"Session captured → daily/{today}.md{status_msg}\n\n{session_entry}",
827817
)]
828818

819+
else:
820+
elif name == "palinode_lint":
821+
from palinode.core.lint import run_lint_pass
822+
import json
823+
result = run_lint_pass()
824+
return [types.TextContent(type="text", text=json.dumps(result, indent=2))]
825+
829826
else:
830827
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
831828

tests/test_lint.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from datetime import datetime, timedelta, timezone
2+
from palinode.core.lint import run_lint_pass
3+
from palinode.core.config import config
4+
import os
5+
6+
def test_lint_pass(tmp_path, monkeypatch):
7+
"""Test the lint logic with simulated memory files."""
8+
monkeypatch.setattr(config, "memory_dir", str(tmp_path))
9+
10+
people_dir = tmp_path / "people"
11+
people_dir.mkdir(parents=True)
12+
13+
# 1. Missing Fields (No id, type, category)
14+
missing1 = people_dir / "missing1.md"
15+
missing1.write_text("---\nstatus: active\n---\nBody", encoding="utf-8")
16+
17+
# 2. Stale Files (Active and older than 90 days)
18+
stale1 = people_dir / "stale1.md"
19+
old_date = (datetime.now(timezone.utc) - timedelta(days=100)).strftime("%Y-%m-%dT%H:%M:%SZ")
20+
stale1.write_text(f"---\nid: people-stale1\ncategory: people\ntype: Person\nstatus: active\ncreated_at: {old_date}\n---\nBody", encoding="utf-8")
21+
22+
# 3. Healthy file referencing 'people/stale1' (meaning stale1 is not an orphan)
23+
healthy1 = people_dir / "healthy1.md"
24+
healthy1.write_text(f"---\nid: people-healthy1\ncategory: people\ntype: Person\nstatus: active\ncreated_at: {(datetime.now(timezone.utc)).strftime('%Y-%m-%dT%H:%M:%SZ')}\nentities:\n - people/stale1\n---\nBody", encoding="utf-8")
25+
26+
# 4. Orphaned file (No entities, and nobody references it)
27+
orphan1 = people_dir / "orphan1.md"
28+
orphan1.write_text("---\nid: people-orphan1\ncategory: people\ntype: Person\nstatus: active\n---\nBody", encoding="utf-8")
29+
30+
# 5. Contradiction file 1
31+
contra1 = people_dir / "contra.md"
32+
contra1.write_text("---\nid: people-contra\ncategory: people\ntype: Person\nstatus: active\n---\nBody", encoding="utf-8")
33+
34+
# 6. Contradiction file 2 (duplicate entity slug, both active)
35+
contra2 = tmp_path / "insights"
36+
contra2.mkdir(exist_ok=True)
37+
contra2_file = contra2 / "contra.md"
38+
# Actually wait, our contradiction logic creates entity ref like f"{cat}/{slug}".
39+
# Let's put it in the same category!
40+
contra_dup = people_dir / "contra-status.md"
41+
contra_dup.write_text("---\nid: people-contra-dup\ncategory: people\ntype: Person\nstatus: active\n---\nBody", encoding="utf-8")
42+
43+
result = run_lint_pass()
44+
45+
# Validate missing fields
46+
assert any(mf["file"].endswith("missing1.md") and "id" in mf["missing"] for mf in result["missing_fields"])
47+
48+
# Validate stale
49+
assert any(sf["file"].endswith("stale1.md") for sf in result["stale_files"])
50+
51+
# Validate orphan
52+
assert any(of.endswith("orphan1.md") for of in result["orphaned_files"])
53+
assert not any(of.endswith("stale1.md") for of in result["orphaned_files"]) # referenced!
54+
55+
# Validate contradictions
56+
assert any(ct["entity"] == "people/contra" for ct in result["contradictions"])

0 commit comments

Comments
 (0)