aaak-vault-sync 1.0.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/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "aaak-vault-sync",
3
+ "version": "1.0.0",
4
+ "description": "Sync an Obsidian vault to AAAK memory format for LLM context loading",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=14"
8
+ },
9
+ "bin": {
10
+ "aaak-scan": "bin/aaak-scan.js"
11
+ },
12
+ "scripts": {
13
+ "setup": "node scripts/setup.js"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "scripts/",
18
+ "templates/",
19
+ "scan.py",
20
+ "dialect.py"
21
+ ],
22
+ "keywords": [
23
+ "obsidian",
24
+ "llm",
25
+ "memory",
26
+ "aaak",
27
+ "claude"
28
+ ]
29
+ }
package/scan.py ADDED
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ scan.py — Obsidian vault → AAAK memory sync
4
+
5
+ Scans an Obsidian vault for new or updated markdown files, converts them
6
+ to AAAK format using dialect.py, and maintains an index at $VAULT/aaak/aaak_index.md.
7
+
8
+ Usage:
9
+ OBSIDIAN_VAULT_PATH=/path/to/vault python scan.py
10
+ OBSIDIAN_VAULT_PATH=/path/to/vault python scan.py --dry-run
11
+ OBSIDIAN_VAULT_PATH=/path/to/vault python scan.py --verbose
12
+ OBSIDIAN_VAULT_PATH=/path/to/vault python scan.py --force # re-scan all files
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import json
18
+ import re
19
+ import argparse
20
+ from pathlib import Path
21
+ from datetime import datetime, timezone
22
+
23
+ # Import Dialect from the same directory as scan.py
24
+ sys.path.insert(0, str(Path(__file__).parent))
25
+ from dialect import Dialect
26
+
27
+ # === CONSTANTS ===
28
+
29
+ AAAK_SUBDIR = "aaak"
30
+ INDEX_FILENAME = "aaak_index.md"
31
+ INDEX_JSON_FILENAME = "aaak_index.json"
32
+ ENTITIES_FILENAME = "entities.json"
33
+
34
+
35
+ # === ENTITY AUTO-DETECTION ===
36
+
37
+ # Match two or more consecutive Title-Case words on the same line (people, orgs, project names)
38
+ # Use [ \t]+ instead of \s+ to avoid matching across newlines
39
+ _NAME_RE = re.compile(r'\b([A-Z][a-z]+(?:[ \t]+[A-Z][a-z]+)+)\b')
40
+
41
+
42
+ def _generate_code(name: str, used: set) -> str:
43
+ """Generate a unique 3-char code for a proper noun name."""
44
+ parts = name.split()
45
+ # e.g. "Alice Johnson" -> "ALJ", "Project Falcon" -> "PRF"
46
+ base = (parts[0][:2] + parts[-1][0]).upper()
47
+ code = base
48
+ suffix = 0
49
+ while code in used:
50
+ suffix += 1
51
+ code = base[:2] + str(suffix)
52
+ return code
53
+
54
+
55
+ def detect_entities(vault_path: Path, existing: dict) -> dict:
56
+ """
57
+ Scan vault markdown files for capitalized proper nouns.
58
+ Returns merged dict: {full_name: 3-char-code}.
59
+ Preserves existing codes so they remain stable across runs.
60
+ """
61
+ aaak_dir = vault_path / AAAK_SUBDIR
62
+ found = set()
63
+
64
+ for md_file in vault_path.rglob("*.md"):
65
+ if aaak_dir in md_file.parents or md_file.parent == aaak_dir:
66
+ continue
67
+ try:
68
+ text = md_file.read_text(errors="replace")
69
+ found.update(_NAME_RE.findall(text))
70
+ except OSError:
71
+ pass # skip unreadable files
72
+
73
+ entities = dict(existing) # preserve existing codes
74
+ used_codes = set(entities.values())
75
+
76
+ for name in sorted(found):
77
+ if name not in entities:
78
+ code = _generate_code(name, used_codes)
79
+ entities[name] = code
80
+ used_codes.add(code)
81
+
82
+ return entities
83
+
84
+
85
+ def load_entities(aaak_dir: Path) -> dict:
86
+ """Load entities.json from aaak dir. Returns empty dict if not found."""
87
+ path = aaak_dir / ENTITIES_FILENAME
88
+ if not path.exists():
89
+ return {}
90
+ try:
91
+ return json.loads(path.read_text())
92
+ except (json.JSONDecodeError, OSError):
93
+ return {}
94
+
95
+
96
+ def save_entities(aaak_dir: Path, entities: dict, dry_run: bool = False) -> None:
97
+ """Write entities.json to aaak dir."""
98
+ if dry_run:
99
+ return
100
+ (aaak_dir / ENTITIES_FILENAME).write_text(
101
+ json.dumps(entities, indent=2, sort_keys=True) + "\n"
102
+ )
103
+
104
+
105
+ # === INDEX MANAGEMENT ===
106
+
107
+ def load_index(aaak_dir: Path) -> dict:
108
+ """
109
+ Load aaak_index.md. Returns dict keyed by source rel_path.
110
+ Parses the embedded JSON block between <!-- INDEX_DATA and -->.
111
+ Falls back to empty dict if file missing or malformed.
112
+ """
113
+ index_path = aaak_dir / INDEX_FILENAME
114
+ if not index_path.exists():
115
+ return {}
116
+ try:
117
+ text = index_path.read_text()
118
+ match = re.search(r'<!-- INDEX_DATA\n(.*?)\n-->', text, re.DOTALL)
119
+ if match:
120
+ return json.loads(match.group(1))
121
+ except (OSError, json.JSONDecodeError) as e:
122
+ print(f"[warn] Could not parse existing index ({e}), starting fresh")
123
+ return {}
124
+
125
+
126
+ def save_index(aaak_dir: Path, index: dict, dry_run: bool = False) -> None:
127
+ """
128
+ Write both:
129
+ - aaak_index.md for humans and text-native LLMs
130
+ - aaak_index.json for tooling and provider-agnostic integrations
131
+ """
132
+ entries = sorted(index.values(), key=lambda e: e["source"])
133
+
134
+ lines = [
135
+ "# AAAK Memory Index",
136
+ "",
137
+ "_Auto-generated by scan.py. Check this file to find vault context relevant to your current task._",
138
+ "",
139
+ "## How to Use",
140
+ "",
141
+ "1. Scan the **Topics** column for keywords relevant to the current conversation.",
142
+ "2. If relevant, read the linked AAAK file for a compressed summary.",
143
+ "3. If you need full detail, follow the `SOURCE:` line in the AAAK file back to the original markdown.",
144
+ "",
145
+ "## Tracked Files",
146
+ "",
147
+ "| Source | AAAK | Last Scanned | Topics |",
148
+ "|--------|------|--------------|--------|",
149
+ ]
150
+
151
+ for entry in entries:
152
+ source = entry["source"]
153
+ aaak = entry["aaak_path"]
154
+ scanned = entry["last_scanned"][:10] # date only
155
+ summary = entry.get("summary", "")
156
+ lines.append(f"| `{source}` | `{aaak}` | {scanned} | {summary} |")
157
+
158
+ lines += [
159
+ "",
160
+ "---",
161
+ "",
162
+ "<!-- INDEX_DATA",
163
+ json.dumps(index, indent=2, sort_keys=True),
164
+ "-->",
165
+ ]
166
+
167
+ md_content = "\n".join(lines) + "\n"
168
+ json_content = json.dumps(
169
+ {
170
+ "version": 1,
171
+ "generated_at": datetime.now(timezone.utc).isoformat(),
172
+ "entries": entries,
173
+ },
174
+ indent=2,
175
+ sort_keys=True,
176
+ ) + "\n"
177
+
178
+ if dry_run:
179
+ print(f"[dry-run] Would write {aaak_dir / INDEX_FILENAME} ({len(entries)} entries)")
180
+ print(f"[dry-run] Would write {aaak_dir / INDEX_JSON_FILENAME} ({len(entries)} entries)")
181
+ return
182
+
183
+ (aaak_dir / INDEX_FILENAME).write_text(md_content)
184
+ (aaak_dir / INDEX_JSON_FILENAME).write_text(json_content)
185
+
186
+
187
+ # === VAULT WALKER ===
188
+
189
+ def find_candidates(vault_path: Path, index: dict, force: bool = False) -> list:
190
+ """
191
+ Return list of vault .md files that need processing:
192
+ - Not yet in index (new files)
193
+ - In index but mtime > last_scanned_ts (updated files)
194
+ - All files if force=True
195
+ """
196
+ aaak_dir = vault_path / AAAK_SUBDIR
197
+ candidates = []
198
+
199
+ for md_file in sorted(vault_path.rglob("*.md")):
200
+ # Skip the aaak/ output directory to prevent recursion
201
+ if aaak_dir in md_file.parents or md_file.parent == aaak_dir:
202
+ continue
203
+
204
+ rel_path = str(md_file.relative_to(vault_path))
205
+ mtime = md_file.stat().st_mtime
206
+
207
+ if force or rel_path not in index:
208
+ candidates.append(md_file)
209
+ elif mtime > index[rel_path]["last_scanned_ts"]:
210
+ candidates.append(md_file)
211
+
212
+ return candidates
213
+
214
+
215
+ # === AAAK CONVERSION ===
216
+
217
+ def slug_from_path(rel_path: str) -> str:
218
+ """
219
+ Convert a relative vault path to a safe AAAK filename slug.
220
+ Example: "projects/my-note.md" -> "projects--my-note.aaak.md"
221
+ """
222
+ return rel_path.replace("/", "--").replace("\\", "--").replace(".md", ".aaak.md")
223
+
224
+
225
+ def _extract_index_summary(aaak_content: str) -> str:
226
+ """Pull topic keywords from the first zettel content line for index display."""
227
+ for line in aaak_content.splitlines():
228
+ if "|" not in line:
229
+ continue
230
+ if line.startswith("T:") or line.startswith("ARC:"):
231
+ continue
232
+ parts = line.split("|")
233
+ if len(parts) < 2:
234
+ continue
235
+ first_field = parts[0].strip()
236
+ # Zettel content lines start with a ZID: "0:ENTITIES" (contains a colon)
237
+ # Header lines look like "wing|room|date|title" (no colon in first field)
238
+ if ":" not in first_field:
239
+ continue # skip header line
240
+ candidate = parts[1].strip()
241
+ if candidate:
242
+ return candidate[:80]
243
+ return ""
244
+
245
+
246
+ def convert_file(
247
+ md_file: Path,
248
+ vault_path: Path,
249
+ aaak_dir: Path,
250
+ dialect: Dialect,
251
+ dry_run: bool = False,
252
+ verbose: bool = False,
253
+ ) -> dict:
254
+ """
255
+ Compress one markdown file to AAAK format. Returns an index entry dict.
256
+ Writes the AAAK file to aaak_dir unless dry_run=True.
257
+ """
258
+ rel_path = str(md_file.relative_to(vault_path))
259
+ slug = slug_from_path(rel_path)
260
+ aaak_path = aaak_dir / slug
261
+
262
+ try:
263
+ content = md_file.read_text(errors="replace")
264
+ except OSError as e:
265
+ print(f"[warn] Could not read {rel_path}: {e}")
266
+ return None
267
+
268
+ mtime = md_file.stat().st_mtime
269
+ date_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d")
270
+
271
+ metadata = {
272
+ "source_file": rel_path,
273
+ "date": date_str,
274
+ }
275
+
276
+ try:
277
+ aaak_content = dialect.compress(content, metadata)
278
+ except Exception as e:
279
+ print(f"[warn] Could not compress {rel_path}: {e}")
280
+ return None
281
+
282
+ # Prepend SOURCE reference so Claude can trace back to the original
283
+ full_output = f"SOURCE: {rel_path}\n{aaak_content}\n"
284
+
285
+ if verbose:
286
+ print(f" [convert] {rel_path} -> {slug}")
287
+
288
+ if not dry_run:
289
+ aaak_path.parent.mkdir(parents=True, exist_ok=True)
290
+ aaak_path.write_text(full_output)
291
+
292
+ summary = _extract_index_summary(aaak_content)
293
+
294
+ return {
295
+ "source": rel_path,
296
+ "aaak_path": str(aaak_path.relative_to(vault_path)),
297
+ "last_scanned_ts": mtime,
298
+ "last_scanned": datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat(),
299
+ "summary": summary,
300
+ }
301
+
302
+
303
+ # === MAIN ===
304
+
305
+ def main():
306
+ parser = argparse.ArgumentParser(
307
+ description="Sync Obsidian vault to AAAK memory format.",
308
+ epilog="Set OBSIDIAN_VAULT_PATH env var to your vault directory.",
309
+ )
310
+ parser.add_argument(
311
+ "--dry-run",
312
+ action="store_true",
313
+ help="Show what would be done without writing any files",
314
+ )
315
+ parser.add_argument(
316
+ "--verbose", "-v",
317
+ action="store_true",
318
+ help="Print each file being processed",
319
+ )
320
+ parser.add_argument(
321
+ "--force", "-f",
322
+ action="store_true",
323
+ help="Re-scan all files, even if they haven't changed",
324
+ )
325
+ args = parser.parse_args()
326
+
327
+ # --- Validate vault path ---
328
+ vault_str = os.environ.get("OBSIDIAN_VAULT_PATH", "").strip()
329
+ if not vault_str:
330
+ print("Error: OBSIDIAN_VAULT_PATH environment variable is not set.")
331
+ print("Usage: OBSIDIAN_VAULT_PATH=/path/to/vault python scan.py")
332
+ sys.exit(1)
333
+
334
+ vault_path = Path(vault_str)
335
+ if not vault_path.exists():
336
+ print(f"Error: Vault path does not exist: {vault_path}")
337
+ sys.exit(1)
338
+ if not vault_path.is_dir():
339
+ print(f"Error: Vault path is not a directory: {vault_path}")
340
+ sys.exit(1)
341
+
342
+ aaak_dir = vault_path / AAAK_SUBDIR
343
+
344
+ # --- Create aaak/ dir ---
345
+ if not dry_run_guard(args.dry_run, aaak_dir):
346
+ return
347
+
348
+ # --- Load existing state ---
349
+ index = load_index(aaak_dir)
350
+ existing_entities = load_entities(aaak_dir)
351
+
352
+ if args.verbose:
353
+ print(f"Vault: {vault_path}")
354
+ print(f"Index: {len(index)} tracked files")
355
+ print(f"Entities: {len(existing_entities)} known")
356
+
357
+ # --- Detect entities from vault ---
358
+ entities = detect_entities(vault_path, existing_entities)
359
+ new_entity_count = len(entities) - len(existing_entities)
360
+ if args.verbose and new_entity_count > 0:
361
+ print(f"Entities: found {new_entity_count} new proper nouns")
362
+
363
+ # --- Build Dialect instance with detected entities ---
364
+ dialect = Dialect(entities=entities)
365
+
366
+ # --- Find candidates ---
367
+ candidates = find_candidates(vault_path, index, force=args.force)
368
+
369
+ if args.verbose or args.dry_run:
370
+ print(f"Candidates: {len(candidates)} file(s) to process")
371
+
372
+ if not candidates:
373
+ print("Nothing to do — all files are up to date.")
374
+ if args.verbose:
375
+ save_entities(aaak_dir, entities, dry_run=args.dry_run)
376
+ return
377
+
378
+ # --- Process each candidate ---
379
+ processed = 0
380
+ skipped = 0
381
+
382
+ for md_file in candidates:
383
+ entry = convert_file(
384
+ md_file, vault_path, aaak_dir, dialect,
385
+ dry_run=args.dry_run, verbose=args.verbose,
386
+ )
387
+ if entry is not None:
388
+ index[entry["source"]] = entry
389
+ processed += 1
390
+ else:
391
+ skipped += 1
392
+
393
+ # --- Save index and entities ---
394
+ save_index(aaak_dir, index, dry_run=args.dry_run)
395
+ save_entities(aaak_dir, entities, dry_run=args.dry_run)
396
+
397
+ # --- Report ---
398
+ action = "[dry-run] Would process" if args.dry_run else "Processed"
399
+ print(f"{action} {processed} file(s)", end="")
400
+ if skipped:
401
+ print(f", skipped {skipped} (errors)", end="")
402
+ print(f". Index: {len(index)} total tracked.")
403
+
404
+
405
+ def dry_run_guard(is_dry_run: bool, aaak_dir: Path) -> bool:
406
+ """
407
+ Ensure aaak_dir exists (or report it would be created in dry-run mode).
408
+ Returns False if creation failed and execution should stop.
409
+ """
410
+ if aaak_dir.exists():
411
+ return True
412
+ if is_dry_run:
413
+ print(f"[dry-run] Would create directory: {aaak_dir}")
414
+ return True
415
+ try:
416
+ aaak_dir.mkdir(parents=True, exist_ok=True)
417
+ return True
418
+ except OSError as e:
419
+ print(f"Error: Could not create aaak directory {aaak_dir}: {e}")
420
+ return False
421
+
422
+
423
+ if __name__ == "__main__":
424
+ main()
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * aaak-vault-sync setup
6
+ *
7
+ * Installs:
8
+ * 1. launchd agent → ~/Library/LaunchAgents/com.aaak.vault-sync.plist
9
+ * 2. generic prompt file → ~/.aaak/generic-memory-loader.md
10
+ * 3. optional Claude files → ~/.claude/skills/scan-vault/SKILL.md and ~/.claude/CLAUDE.md
11
+ *
12
+ * Usage:
13
+ * npm run setup
14
+ * node scripts/setup.js
15
+ * node scripts/setup.js --target none
16
+ * node scripts/setup.js --target claude
17
+ * OBSIDIAN_VAULT_PATH=/path/to/vault node scripts/setup.js --target claude
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+ const { execSync } = require('child_process');
24
+
25
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
26
+ const HOME = os.homedir();
27
+ const PLATFORM = os.platform();
28
+
29
+ // ── Paths ──────────────────────────────────────────────────────────────────
30
+
31
+ const LAUNCH_AGENTS_DIR = path.join(HOME, 'Library', 'LaunchAgents');
32
+ const PLIST_DEST = path.join(LAUNCH_AGENTS_DIR, 'com.aaak.vault-sync.plist');
33
+ const CLAUDE_DIR = path.join(HOME, '.claude');
34
+ const SKILLS_DIR = path.join(CLAUDE_DIR, 'skills', 'scan-vault');
35
+ const SKILL_DEST = path.join(SKILLS_DIR, 'SKILL.md');
36
+ const CLAUDE_MD_PATH = path.join(CLAUDE_DIR, 'CLAUDE.md');
37
+ const AAAK_DIR = path.join(HOME, '.aaak');
38
+ const GENERIC_PROMPT_DEST = path.join(AAAK_DIR, 'generic-memory-loader.md');
39
+
40
+ const PLIST_TEMPLATE = path.join(PACKAGE_ROOT, 'templates', 'com.aaak.vault-sync.plist.template');
41
+ const SKILL_TEMPLATE = path.join(PACKAGE_ROOT, 'templates', 'scan-vault-skill.md.template');
42
+ const GENERIC_PROMPT_TEMPLATE = path.join(PACKAGE_ROOT, 'templates', 'generic-memory-loader.md.template');
43
+ const SCAN_PY = path.join(PACKAGE_ROOT, 'scan.py');
44
+
45
+ // ── Helpers ────────────────────────────────────────────────────────────────
46
+
47
+ function findPython() {
48
+ for (const cmd of ['python3', 'python']) {
49
+ try {
50
+ const p = execSync(`which ${cmd} 2>/dev/null`).toString().trim();
51
+ if (p) return p;
52
+ } catch (_) {}
53
+ }
54
+ return '/usr/bin/python3'; // safe fallback on macOS
55
+ }
56
+
57
+ function ensureDir(dir) {
58
+ if (!fs.existsSync(dir)) {
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ }
61
+ }
62
+
63
+ function log(msg) { console.log(` ${msg}`); }
64
+ function ok(msg) { console.log(`\u2713 ${msg}`); }
65
+ function warn(msg) { console.log(`\u26a0 ${msg}`); }
66
+ function bold(msg) { return `\x1b[1m${msg}\x1b[0m`; }
67
+
68
+ // ── Steps ──────────────────────────────────────────────────────────────────
69
+
70
+ function installLaunchdAgent(vaultPath, python) {
71
+ if (PLATFORM !== 'darwin') {
72
+ warn('Skipping launchd agent (macOS only). Set up a cron job manually instead:');
73
+ log(` crontab -e → 0 * * * * OBSIDIAN_VAULT_PATH=${vaultPath || '/path/to/vault'} ${python} ${SCAN_PY}`);
74
+ return;
75
+ }
76
+
77
+ const template = fs.readFileSync(PLIST_TEMPLATE, 'utf8');
78
+ const plist = template
79
+ .replace('{{PYTHON}}', python)
80
+ .replace('{{SCAN_PY}}', SCAN_PY)
81
+ .replace('{{VAULT_PATH}}', vaultPath || 'YOUR_VAULT_PATH_HERE');
82
+
83
+ ensureDir(LAUNCH_AGENTS_DIR);
84
+ fs.writeFileSync(PLIST_DEST, plist);
85
+ ok(`Plist written: ${PLIST_DEST}`);
86
+
87
+ if (!vaultPath) {
88
+ warn(`OBSIDIAN_VAULT_PATH not set — edit the plist before loading:`);
89
+ log(` ${PLIST_DEST}`);
90
+ log(` Replace YOUR_VAULT_PATH_HERE with your vault path, then:`);
91
+ log(` launchctl load "${PLIST_DEST}"`);
92
+ } else {
93
+ log(`Vault path: ${vaultPath}`);
94
+ log(`To activate: launchctl load "${PLIST_DEST}"`);
95
+ }
96
+ }
97
+
98
+ function installGenericPromptFile() {
99
+ ensureDir(AAAK_DIR);
100
+ const prompt = fs.readFileSync(GENERIC_PROMPT_TEMPLATE, 'utf8');
101
+ fs.writeFileSync(GENERIC_PROMPT_DEST, prompt);
102
+ ok(`Generic prompt installed: ${GENERIC_PROMPT_DEST}`);
103
+ log('Use this file with any LLM that supports system prompts or custom instructions');
104
+ }
105
+
106
+ function installClaudeSkill() {
107
+ if (!fs.existsSync(CLAUDE_DIR)) {
108
+ warn(`~/.claude/ not found — is Claude Code installed? Skipping skill install.`);
109
+ log(`You can install manually later: mkdir -p ${SKILLS_DIR} && cp ${SKILL_TEMPLATE} ${SKILL_DEST}`);
110
+ return;
111
+ }
112
+
113
+ ensureDir(SKILLS_DIR);
114
+ const skill = fs.readFileSync(SKILL_TEMPLATE, 'utf8');
115
+ fs.writeFileSync(SKILL_DEST, skill);
116
+ ok(`Skill installed: ${SKILL_DEST}`);
117
+ log(`Invoke with /scan-vault in any Claude Code session`);
118
+ }
119
+
120
+ function installClaudeMdRule() {
121
+ const rule = [
122
+ '',
123
+ '## Obsidian Vault Memory',
124
+ '',
125
+ 'I maintain an AAAK memory index of my Obsidian vault at:',
126
+ '`$OBSIDIAN_VAULT_PATH/aaak/aaak_index.md`',
127
+ '',
128
+ '**At the start of each session**, if `OBSIDIAN_VAULT_PATH` is set and the index file exists:',
129
+ '1. Read `$OBSIDIAN_VAULT_PATH/aaak/aaak_index.md`',
130
+ '2. Scan the Topics column for entries relevant to what the user is working on',
131
+ '3. For relevant entries, read the linked AAAK file (column 2, relative to vault root) for a compressed summary',
132
+ '4. If deeper detail is needed, follow the `SOURCE:` line in the AAAK file to read the original markdown',
133
+ '',
134
+ '**During the session**, re-check the index if a new topic arises that might have vault context.',
135
+ '',
136
+ 'If `OBSIDIAN_VAULT_PATH` is not set or the index file does not exist yet, silently skip.',
137
+ '',
138
+ 'To refresh the vault index from within a session, use the `/scan-vault` skill.',
139
+ '',
140
+ ].join('\n');
141
+
142
+ if (!fs.existsSync(CLAUDE_DIR)) {
143
+ warn(`~/.claude/ not found — skipping CLAUDE.md update.`);
144
+ return;
145
+ }
146
+
147
+ if (fs.existsSync(CLAUDE_MD_PATH)) {
148
+ const existing = fs.readFileSync(CLAUDE_MD_PATH, 'utf8');
149
+ if (existing.includes('## Obsidian Vault Memory')) {
150
+ ok(`CLAUDE.md already has vault memory rule — skipping`);
151
+ return;
152
+ }
153
+ fs.appendFileSync(CLAUDE_MD_PATH, rule);
154
+ ok(`CLAUDE.md updated: ${CLAUDE_MD_PATH}`);
155
+ } else {
156
+ fs.writeFileSync(CLAUDE_MD_PATH, rule.trimStart());
157
+ ok(`CLAUDE.md created: ${CLAUDE_MD_PATH}`);
158
+ }
159
+ }
160
+
161
+ // ── Main ───────────────────────────────────────────────────────────────────
162
+
163
+ function main() {
164
+ console.log(bold('\naaak-vault-sync setup\n'));
165
+
166
+ const vaultPath = process.env.OBSIDIAN_VAULT_PATH || '';
167
+ const python = findPython();
168
+ const args = process.argv.slice(2);
169
+ const targetIdx = args.indexOf('--target');
170
+ const target = targetIdx >= 0 && args[targetIdx + 1] ? args[targetIdx + 1] : 'none';
171
+
172
+ log(`scan.py: ${SCAN_PY}`);
173
+ log(`python3: ${python}`);
174
+ log(`vault: ${vaultPath || '(not set; OBSIDIAN_VAULT_PATH is empty)'}`);
175
+ log(`target: ${target}\n`);
176
+
177
+ installLaunchdAgent(vaultPath, python);
178
+ console.log('');
179
+ installGenericPromptFile();
180
+
181
+ if (target === 'claude') {
182
+ console.log('');
183
+ installClaudeSkill();
184
+ console.log('');
185
+ installClaudeMdRule();
186
+ } else {
187
+ console.log('');
188
+ log('Skipping Claude-specific integration. Use --target claude to install it.');
189
+ }
190
+
191
+ console.log(bold('\nNext steps:'));
192
+ if (!vaultPath) {
193
+ log('1. Add to ~/.zshrc (or ~/.bashrc):');
194
+ log(' export OBSIDIAN_VAULT_PATH=/path/to/your/vault');
195
+ log(' Then re-run: npm run setup');
196
+ log('');
197
+ }
198
+ if (PLATFORM === 'darwin') {
199
+ const loadCmd = `launchctl load "${PLIST_DEST}"`;
200
+ log(`${vaultPath ? '1' : '2'}. Load the scheduler:`);
201
+ log(` ${loadCmd}`);
202
+ log('');
203
+ log(`${vaultPath ? '2' : '3'}. Run a manual sync to verify:`);
204
+ } else {
205
+ log(`${vaultPath ? '1' : '2'}. Run a manual sync to verify:`);
206
+ }
207
+ log(' aaak-scan --verbose');
208
+ log('');
209
+ log(`${vaultPath ? '3' : '4'}. Reuse this prompt with any LLM:`);
210
+ log(` ${GENERIC_PROMPT_DEST}`);
211
+ console.log('');
212
+ }
213
+
214
+ main();
@@ -0,0 +1,33 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4
+ <plist version="1.0">
5
+ <dict>
6
+ <key>Label</key>
7
+ <string>com.aaak.vault-sync</string>
8
+
9
+ <key>ProgramArguments</key>
10
+ <array>
11
+ <string>{{PYTHON}}</string>
12
+ <string>{{SCAN_PY}}</string>
13
+ </array>
14
+
15
+ <key>EnvironmentVariables</key>
16
+ <dict>
17
+ <key>OBSIDIAN_VAULT_PATH</key>
18
+ <string>{{VAULT_PATH}}</string>
19
+ </dict>
20
+
21
+ <key>StartInterval</key>
22
+ <integer>3600</integer>
23
+
24
+ <key>StandardOutPath</key>
25
+ <string>/tmp/aaak-vault-sync.log</string>
26
+
27
+ <key>StandardErrorPath</key>
28
+ <string>/tmp/aaak-vault-sync.err</string>
29
+
30
+ <key>RunAtLoad</key>
31
+ <true/>
32
+ </dict>
33
+ </plist>