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/README.md +294 -0
- package/bin/aaak-scan.js +24 -0
- package/dialect.py +1075 -0
- package/package.json +29 -0
- package/scan.py +424 -0
- package/scripts/setup.js +214 -0
- package/templates/com.aaak.vault-sync.plist.template +33 -0
- package/templates/generic-memory-loader.md.template +13 -0
- package/templates/scan-vault-skill.md.template +32 -0
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()
|
package/scripts/setup.js
ADDED
|
@@ -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>
|