kyp-mem 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,49 +1,59 @@
1
1
  # KYP-MEM — Know Your Project Memory
2
2
 
3
- **Headless knowledge base for AI agents.** Markdown notes with wikilinks, backlinks, tags, related notes, and a neon web UI — all powered by an MCP server so Claude (or any AI) can read and write your project knowledge directly.
3
+ **Persistent knowledge base for AI agents.** Markdown vault with wikilinks, backlinks, tags, graph navigation, and auto-learning — all powered by an MCP server so Claude (or any AI) can read and write project knowledge across sessions.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npx -y kyp-mem
8
+ npm install -g kyp-mem
9
9
  ```
10
10
 
11
- Or install the command globally:
11
+ Or run directly:
12
12
 
13
13
  ```bash
14
- npm install -g kyp-mem
14
+ npx -y kyp-mem
15
15
  ```
16
16
 
17
- ## Setup (3 commands)
17
+ ## Setup
18
18
 
19
19
  ```bash
20
- # 1. Choose where your vault (knowledge base) lives
21
- npx -y kyp-mem init
20
+ kyp-mem init # Choose vault location
21
+ kyp-mem setup-claude # Auto-configure Claude Code MCP
22
+ kyp-mem install-hooks # Enable auto-learning from sessions
23
+ ```
24
+
25
+ Restart Claude Code. Done — kyp-mem runs headlessly every session with 9 tools available.
22
26
 
23
- # 2. Connect to Claude Code — auto-configures MCP
24
- npx -y kyp-mem setup-claude
27
+ ## Auto-Learning
25
28
 
26
- # 3. Restart Claude Code — done!
27
- # kyp-mem now runs headlessly every session.
28
- # Claude can read/write/search your knowledge base.
29
+ KYP-MEM can automatically capture what happens in every Claude Code session:
30
+
31
+ ```bash
32
+ kyp-mem install-hooks --global
29
33
  ```
30
34
 
31
- That's it. Claude now has `kyp_read`, `kyp_write`, `kyp_search`, and 7 other tools available in every session.
35
+ This installs two hooks:
36
+ - **PostToolUse** — captures file edits, writes, and commands (pure Node, fast)
37
+ - **Stop** — compiles session activity into a vault note under `Sessions/`
32
38
 
33
- ## Optional: Web UI
39
+ Sessions with fewer than 3 substantive actions are automatically skipped.
40
+
41
+ ## Web UI
34
42
 
35
43
  ```bash
36
- npx -y kyp-mem ui
44
+ kyp-mem ui
37
45
  ```
38
46
 
39
- Opens a rich interface at `localhost:3333` with:
40
- - Collapsible folder tree
41
- - Rendered markdown with syntax highlighting
42
- - Clickable `[[wikilinks]]`
43
- - Backlinks and related notes panel
44
- - Interactive D3 graph view (toggleable)
47
+ Opens at `localhost:3333` with:
48
+ - Quick switcher (`Cmd+O`) — fuzzy jump to any note
45
49
  - Full-text search (`Cmd+K`)
46
- - Draggable resizable panels
50
+ - Tag filtering — clickable tag cloud, AND-filter
51
+ - Outline panel — heading TOC with click-to-scroll
52
+ - Backlink context — shows the surrounding line
53
+ - Unlinked mentions — finds references without `[[wikilinks]]`
54
+ - Inline editing — edit notes directly in the browser (`Cmd+S`)
55
+ - Local graph view — D3 force-directed graph of connections
56
+ - Resizable panels, collapsible tree, rendered markdown
47
57
 
48
58
  ## How It Works
49
59
 
@@ -55,85 +65,74 @@ Opens a rich interface at `localhost:3333` with:
55
65
  └──────────────┘
56
66
  ```
57
67
 
58
- - **Headless by default** — runs as an MCP server (stdio), no GUI needed
59
- - **Markdown files on disk** — plain `.md` files with YAML frontmatter, no database
60
- - **In-memory index** — links, backlinks, tags, search, similarity scoring
61
- - **Web UI optional** — `kyp-mem ui` when you want to browse visually
68
+ - **Headless by default** — MCP server over stdio, no GUI needed
69
+ - **Markdown on disk** — plain `.md` files with YAML frontmatter, no database
70
+ - **In-memory index** — wikilinks, backlinks, tags, word-level search index
71
+ - **Lightweight reads** — brief mode by default (~100 tokens), full content opt-in
72
+ - **Graph navigation** — follow `[[links]]` instead of searching broadly
62
73
 
63
74
  ## Commands
64
75
 
65
76
  | Command | What it does |
66
77
  |---------|-------------|
67
78
  | `kyp-mem init` | First-time setup — choose vault location |
68
- | `kyp-mem setup-claude` | Register the MCP server with Claude Code for this project |
79
+ | `kyp-mem setup-claude` | Register MCP server with Claude Code |
69
80
  | `kyp-mem setup-claude --global` | Configure globally (all projects) |
81
+ | `kyp-mem install-hooks` | Enable auto-learning from sessions |
82
+ | `kyp-mem install-hooks --remove` | Remove auto-learning hooks |
70
83
  | `kyp-mem serve` | Start MCP server (used by Claude, not you) |
71
84
  | `kyp-mem ui` | Open web UI at localhost:3333 |
72
85
  | `kyp-mem stats` | Print vault statistics |
73
86
  | `kyp-mem tree` | Print vault tree |
74
87
  | `kyp-mem doctor` | Check installation health |
75
88
 
76
- ## MCP Tools (what Claude gets)
89
+ ## MCP Tools (9 tools)
77
90
 
78
91
  | Tool | Description |
79
92
  |------|-------------|
80
- | `kyp_list` | Browse vault folders and notes |
81
- | `kyp_read` | Read a note content + tags + backlinks + related |
93
+ | `kyp_list` | Browse folders and notes with inline tags |
94
+ | `kyp_read` | Brief summary by default; `full=True` for complete content |
82
95
  | `kyp_write` | Create or update a note with tags and properties |
83
96
  | `kyp_delete` | Delete a note |
84
- | `kyp_search` | Full-text search across all notes |
97
+ | `kyp_search` | Full-text search with optional tag filter |
85
98
  | `kyp_tags` | List all tags or filter notes by tag |
86
- | `kyp_related` | Find related notes by links, tags, proximity |
99
+ | `kyp_related` | Find related notes by links, tags, folder proximity |
87
100
  | `kyp_recent` | Recently modified notes |
88
101
  | `kyp_stats` | Vault statistics |
89
102
 
90
103
  ## Note Format
91
104
 
92
- Standard markdown with YAML frontmatter:
93
-
94
105
  ```markdown
95
106
  ---
96
107
  tags: [project, trading, config]
97
- source: config.py
98
108
  created: 2026-05-12
99
- updated: 2026-05-12
100
109
  ---
101
110
 
102
111
  # Configuration
103
112
 
104
- Settings are defined in `HedgeConfig`. See [[Risk Management]] for safety checks.
113
+ Settings are in `HedgeConfig`. See [[Risk Management]] for safety checks.
105
114
  ```
106
115
 
107
- `[[Wikilinks]]` are automatically parsed, indexed, and turned into navigable backlinks.
116
+ `[[Wikilinks]]` are parsed, indexed, and resolved into navigable backlinks automatically.
108
117
 
109
118
  ## Manual Claude Code Config
110
119
 
111
- If you prefer to configure manually instead of using `setup-claude`:
112
-
113
120
  ```bash
114
- claude mcp add -s local -e KYP_VAULT="$HOME/.kyp-mem/vault" kyp-mem -- npx -y kyp-mem serve
121
+ claude mcp add -s user -e KYP_VAULT="$HOME/.kyp-mem/vault" kyp-mem -- npx -y kyp-mem serve
115
122
  ```
116
123
 
117
- Use `-s user` instead of `-s local` to make it available in all projects.
118
-
119
124
  ## Architecture
120
125
 
121
126
  ```
122
127
  ~/.kyp-mem/
123
- ├── config.json # vault path + settings
124
- └── vault/ # your knowledge base
128
+ ├── config.json # vault path
129
+ ├── sessions/ # auto-learning session logs
130
+ └── vault/
125
131
  ├── Project A/
126
132
  │ ├── Architecture.md
127
- │ ├── Configuration.md
128
133
  │ └── Bugs.md
129
- └── Project B/
130
- └── ...
131
- ```
132
-
133
- ## Publishing to npm
134
-
135
- ```bash
136
- npm publish
134
+ ├── Sessions/ # auto-captured session notes
135
+ └── ...
137
136
  ```
138
137
 
139
138
  ## License
package/bin/cli.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from "child_process";
4
- import { delimiter, dirname, resolve } from "path";
4
+ import { appendFileSync, mkdirSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { delimiter, dirname, join, resolve } from "path";
5
7
  import { fileURLToPath } from "url";
6
8
 
7
9
  const args = process.argv.slice(2);
@@ -47,6 +49,59 @@ function findPython() {
47
49
  return null;
48
50
  }
49
51
 
52
+ // --- Hook fast path (pure Node, no Python startup) ---
53
+ if (args[0] === "hook") {
54
+ const hookType = args[1];
55
+ const sessionDir = join(homedir(), ".kyp-mem", "sessions");
56
+ const sessionFile = join(sessionDir, "current.jsonl");
57
+
58
+ const chunks = [];
59
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
60
+ await new Promise((r) => process.stdin.on("end", r));
61
+ const raw = Buffer.concat(chunks).toString();
62
+
63
+ if (hookType === "post-tool-use") {
64
+ try {
65
+ const data = JSON.parse(raw);
66
+ const tool = data.tool_name || "";
67
+ if (tool.includes("kyp-mem") || tool.includes("kyp_mem")) process.exit(0);
68
+
69
+ const input = data.tool_input || {};
70
+ const entry = { ts: new Date().toISOString(), tool, cwd: process.cwd() };
71
+
72
+ if (tool === "Edit" || tool === "Write") {
73
+ entry.file = input.file_path || "";
74
+ entry.action = tool === "Edit" ? "edit" : "create";
75
+ } else if (tool === "Bash") {
76
+ entry.command = (input.command || "").slice(0, 300);
77
+ entry.action = "command";
78
+ } else {
79
+ entry.action = "other";
80
+ entry.detail = tool;
81
+ }
82
+
83
+ mkdirSync(sessionDir, { recursive: true });
84
+ appendFileSync(sessionFile, JSON.stringify(entry) + "\n");
85
+ } catch (_) {
86
+ // silent — hooks must never break the flow
87
+ }
88
+ process.exit(0);
89
+ }
90
+
91
+ if (hookType === "stop") {
92
+ const py = findPython();
93
+ if (py) {
94
+ const [cmd, pre] = py;
95
+ const r = run(cmd, [...pre, "-m", "kyp_mem.hooks", "stop"], "inherit");
96
+ process.exit(r.status ?? 0);
97
+ }
98
+ process.exit(0);
99
+ }
100
+
101
+ console.error("Unknown hook type:", hookType);
102
+ process.exit(1);
103
+ }
104
+
50
105
  const python = findPython();
51
106
 
52
107
  if (python) {
@@ -1,3 +1,3 @@
1
1
  """KYP-MEM — Know Your Project Memory. Headless knowledge base for AI agents."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
package/kyp_mem/cli.py CHANGED
@@ -36,6 +36,11 @@ def main():
36
36
 
37
37
  subparsers.add_parser("stats", help="Print vault statistics")
38
38
  subparsers.add_parser("tree", help="Print vault tree")
39
+ ih = subparsers.add_parser("install-hooks", help="Set up auto-learning hooks for Claude Code")
40
+ ih.add_argument("--global", dest="global_config", action="store_true",
41
+ help="Add hooks to global ~/.claude/settings.json (default: project)")
42
+ ih.add_argument("--remove", action="store_true", help="Remove KYP-MEM hooks")
43
+
39
44
  subparsers.add_parser("doctor", help="Check installation and config health")
40
45
 
41
46
  args = parser.parse_args()
@@ -57,6 +62,8 @@ def main():
57
62
  _run_stats()
58
63
  elif args.command == "tree":
59
64
  _run_tree()
65
+ elif args.command == "install-hooks":
66
+ _run_install_hooks(global_config=args.global_config, remove=args.remove)
60
67
  elif args.command == "doctor":
61
68
  _run_doctor()
62
69
  else:
@@ -237,6 +244,78 @@ def _write_legacy_claude_settings(
237
244
  return settings_path
238
245
 
239
246
 
247
+ def _run_install_hooks(global_config: bool = False, remove: bool = False):
248
+ mcp_command, _ = _get_mcp_command()
249
+
250
+ if global_config:
251
+ settings_path = Path.home() / ".claude" / "settings.json"
252
+ scope_label = "global"
253
+ else:
254
+ settings_path = Path.cwd() / ".claude" / "settings.json"
255
+ scope_label = "project"
256
+
257
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
258
+ settings = {}
259
+ if settings_path.exists():
260
+ try:
261
+ settings = json.loads(settings_path.read_text())
262
+ except json.JSONDecodeError:
263
+ settings = {}
264
+
265
+ hooks = settings.setdefault("hooks", {})
266
+
267
+ if remove:
268
+ changed = False
269
+ for event in ("PostToolUse", "Stop"):
270
+ if event in hooks:
271
+ hooks[event] = [h for h in hooks[event] if "kyp-mem hook" not in h.get("command", "")]
272
+ if not hooks[event]:
273
+ del hooks[event]
274
+ changed = True
275
+ if not hooks:
276
+ del settings["hooks"]
277
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n")
278
+ print()
279
+ print(f" {G}✓{R} KYP-MEM hooks removed from {scope_label} settings")
280
+ print(f" {D} File: {settings_path}{R}")
281
+ print()
282
+ return
283
+
284
+ post_tool_hooks = hooks.setdefault("PostToolUse", [])
285
+ stop_hooks = hooks.setdefault("Stop", [])
286
+
287
+ post_tool_hooks = [h for h in post_tool_hooks if "kyp-mem hook" not in h.get("command", "")]
288
+ stop_hooks = [h for h in stop_hooks if "kyp-mem hook" not in h.get("command", "")]
289
+
290
+ post_tool_hooks.append({
291
+ "matcher": "Edit|Write|Bash",
292
+ "hooks": [{"type": "command", "command": f"{mcp_command} hook post-tool-use"}],
293
+ })
294
+ stop_hooks.append({
295
+ "hooks": [{"type": "command", "command": f"{mcp_command} hook stop"}],
296
+ })
297
+
298
+ hooks["PostToolUse"] = post_tool_hooks
299
+ hooks["Stop"] = stop_hooks
300
+
301
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n")
302
+
303
+ print()
304
+ print(f" {C}KYP-MEM{R} — Auto-Learning Hooks")
305
+ print()
306
+ print(f" {G}✓{R} Hooks installed ({scope_label})")
307
+ print(f" {D} File: {settings_path}{R}")
308
+ print()
309
+ print(f" How it works:")
310
+ print(f" {D} • PostToolUse hook captures file edits, writes, and commands{R}")
311
+ print(f" {D} • Stop hook compiles the session into a vault note{R}")
312
+ print(f" {D} • Notes saved under Sessions/ with timestamps and tags{R}")
313
+ print(f" {D} • Sessions with < 3 substantive actions are skipped{R}")
314
+ print()
315
+ print(f" {C}Done!{R} Restart Claude Code. Sessions will auto-save to your vault.")
316
+ print()
317
+
318
+
240
319
  def _run_stats():
241
320
  from .config import get_vault_path
242
321
  from .vault import Vault
@@ -314,6 +393,28 @@ def _run_doctor():
314
393
  if "kyp-mem" in s.get("mcpServers", {}):
315
394
  print(f" {D}·{R} Legacy Claude settings ({label}): kyp-mem entry present")
316
395
 
396
+ # Hooks
397
+ for label, path in legacy_paths:
398
+ if not path.exists():
399
+ continue
400
+ try:
401
+ s = json.loads(path.read_text())
402
+ except json.JSONDecodeError:
403
+ continue
404
+ hooks = s.get("hooks", {})
405
+ has_post = any("kyp-mem hook" in h.get("command", "") for h in hooks.get("PostToolUse", []))
406
+ has_stop = any("kyp-mem hook" in h.get("command", "") for h in hooks.get("Stop", []))
407
+ if has_post and has_stop:
408
+ print(f" {G}✓{R} Auto-learning hooks installed ({label})")
409
+ elif has_post or has_stop:
410
+ print(f" {Y}!{R} Partial hooks installed ({label}) — run: kyp-mem install-hooks")
411
+
412
+ # Session log
413
+ session_file = Path.home() / ".kyp-mem" / "sessions" / "current.jsonl"
414
+ if session_file.exists():
415
+ line_count = len(session_file.read_text().strip().split("\n"))
416
+ print(f" {D}·{R} Active session log: {line_count} entries")
417
+
317
418
  # Binary
318
419
  kyp_bin = shutil.which("kyp-mem")
319
420
  if kyp_bin:
@@ -0,0 +1,126 @@
1
+ """KYP-MEM session hooks — compile captured tool activity into vault notes."""
2
+
3
+ import sys
4
+ import json
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ SESSION_DIR = Path.home() / ".kyp-mem" / "sessions"
9
+ CURRENT_SESSION = SESSION_DIR / "current.jsonl"
10
+
11
+ MIN_ACTIONS = 3
12
+
13
+
14
+ def handle_stop():
15
+ if not CURRENT_SESSION.exists():
16
+ return
17
+
18
+ text = CURRENT_SESSION.read_text().strip()
19
+ if not text:
20
+ CURRENT_SESSION.unlink(missing_ok=True)
21
+ return
22
+
23
+ entries = []
24
+ for line in text.split("\n"):
25
+ try:
26
+ entries.append(json.loads(line))
27
+ except json.JSONDecodeError:
28
+ continue
29
+
30
+ if not entries:
31
+ CURRENT_SESSION.unlink(missing_ok=True)
32
+ return
33
+
34
+ write_actions = [e for e in entries if e.get("action") in ("edit", "create", "command")]
35
+ if len(write_actions) < MIN_ACTIONS:
36
+ CURRENT_SESSION.unlink(missing_ok=True)
37
+ return
38
+
39
+ project_dir = entries[0].get("cwd", "unknown")
40
+ project_name = Path(project_dir).name
41
+ session_id = datetime.now().strftime("%Y-%m-%d_%H%M%S")
42
+
43
+ files_edited = set()
44
+ files_created = set()
45
+ commands = []
46
+ timeline = []
47
+
48
+ for e in entries:
49
+ ts_raw = e.get("ts", "")
50
+ ts = ts_raw[11:19] if len(ts_raw) >= 19 else ""
51
+ action = e.get("action", "")
52
+
53
+ if action == "edit":
54
+ fp = e.get("file", "")
55
+ files_edited.add(fp)
56
+ timeline.append(f" {ts} — Edit `{Path(fp).name}`")
57
+ elif action == "create":
58
+ fp = e.get("file", "")
59
+ files_created.add(fp)
60
+ timeline.append(f" {ts} — Write `{Path(fp).name}`")
61
+ elif action == "command":
62
+ cmd = e.get("command", "")
63
+ commands.append(cmd)
64
+ short = cmd[:80] + "..." if len(cmd) > 80 else cmd
65
+ timeline.append(f" {ts} — `{short}`")
66
+
67
+ parts = [f"# Session {session_id}", ""]
68
+ parts.append(f"**Project:** `{project_dir}`")
69
+ parts.append(f"**Actions:** {len(entries)} total, {len(write_actions)} substantive")
70
+ parts.append("")
71
+
72
+ if files_edited:
73
+ parts.append("## Files Modified")
74
+ for f in sorted(files_edited):
75
+ parts.append(f"- `{f}`")
76
+ parts.append("")
77
+
78
+ if files_created:
79
+ parts.append("## Files Created")
80
+ for f in sorted(files_created):
81
+ parts.append(f"- `{f}`")
82
+ parts.append("")
83
+
84
+ if commands:
85
+ parts.append("## Commands Run")
86
+ for cmd in commands[:25]:
87
+ short = cmd[:120] + "..." if len(cmd) > 120 else cmd
88
+ parts.append(f"- `{short}`")
89
+ parts.append("")
90
+
91
+ if timeline:
92
+ parts.append("## Timeline")
93
+ for line in timeline[:40]:
94
+ parts.append(line)
95
+ if len(timeline) > 40:
96
+ parts.append(f" ... and {len(timeline) - 40} more actions")
97
+
98
+ content = "\n".join(parts)
99
+ tags = ["session", "auto-captured", project_name]
100
+
101
+ from .config import get_vault_path
102
+ from .vault import Vault
103
+
104
+ vault = Vault(get_vault_path())
105
+ vault.write_note(f"Sessions/{session_id}.md", content, tags, {})
106
+
107
+ CURRENT_SESSION.unlink(missing_ok=True)
108
+
109
+
110
+ def main():
111
+ if len(sys.argv) > 1 and sys.argv[1] == "stop":
112
+ handle_stop()
113
+ else:
114
+ raw = sys.stdin.read().strip()
115
+ if not raw:
116
+ return
117
+ try:
118
+ data = json.loads(raw)
119
+ except json.JSONDecodeError:
120
+ return
121
+ if "stop_reason" in data:
122
+ handle_stop()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
package/kyp_mem/server.py CHANGED
@@ -12,13 +12,18 @@ mcp = FastMCP("kyp-mem")
12
12
 
13
13
  @mcp.tool()
14
14
  def kyp_list(path: str = "") -> str:
15
- """List notes and folders in the vault. Pass a folder path to list its contents, or empty for root."""
15
+ """List notes and folders in the vault. Shows inline tags for quick navigation. Pass a folder path or empty for root."""
16
16
  tree = vault.list_tree(path)
17
17
  lines = []
18
18
  for f in tree["folders"]:
19
19
  lines.append(f" {f}/")
20
20
  for n in tree["notes"]:
21
- lines.append(f" {n}")
21
+ rel = f"{path}/{n}" if path else n
22
+ note = vault.index.notes.get(rel)
23
+ if note and note.tags:
24
+ lines.append(f" {n} [{', '.join(note.tags)}]")
25
+ else:
26
+ lines.append(f" {n}")
22
27
  if not lines:
23
28
  lines.append("(empty vault)")
24
29
  header = f"Vault: {path or '/'}"
@@ -26,24 +31,46 @@ def kyp_list(path: str = "") -> str:
26
31
 
27
32
 
28
33
  @mcp.tool()
29
- def kyp_read(path: str) -> str:
30
- """Read a note by path (e.g. 'Hedge Engine/Configuration.md'). Returns content + properties + backlinks + related notes."""
34
+ def kyp_read(path: str, full: bool = False) -> str:
35
+ """Read a note. Returns brief summary by default (title, tags, preview, links). Set full=True for complete content."""
31
36
  note = vault.read(path)
32
37
  if not note:
33
38
  return f"Not found: {path}"
34
39
 
40
+ if not full:
41
+ parts = [f"# {note.title}"]
42
+ if note.tags:
43
+ parts.append(f"tags: {', '.join(note.tags)}")
44
+ if note.created:
45
+ parts.append(f"created: {note.created}")
46
+
47
+ lines = [l for l in note.content.strip().split("\n") if l.strip() and not l.startswith("# ")]
48
+ preview = "\n".join(lines[:6])
49
+ if len(lines) > 6:
50
+ preview += "\n..."
51
+ parts.append("")
52
+ parts.append(preview)
53
+
54
+ backlinks = vault.get_backlinks(path)
55
+ outlinks = note.links
56
+ if outlinks:
57
+ parts.append(f"\nlinks: {', '.join(f'[[{l}]]' for l in outlinks)}")
58
+ if backlinks:
59
+ parts.append(f"backlinks: {', '.join(f'[[{b.replace('.md', '')}]]' for b in backlinks)}")
60
+
61
+ return "\n".join(parts)
62
+
35
63
  parts = [f"# {note.title}", ""]
36
64
 
37
65
  if note.tags or note.properties or note.created:
38
- parts.append("**Properties:**")
39
66
  if note.tags:
40
- parts.append(f" tags: {', '.join(note.tags)}")
67
+ parts.append(f"tags: {', '.join(note.tags)}")
41
68
  if note.created:
42
- parts.append(f" created: {note.created}")
69
+ parts.append(f"created: {note.created}")
43
70
  if note.updated:
44
- parts.append(f" updated: {note.updated}")
71
+ parts.append(f"updated: {note.updated}")
45
72
  for k, v in note.properties.items():
46
- parts.append(f" {k}: {v}")
73
+ parts.append(f"{k}: {v}")
47
74
  parts.append("")
48
75
 
49
76
  parts.append(note.content)