diff --git a/README.md b/README.md index c40342d..2a2116d 100644 --- a/README.md +++ b/README.md @@ -1341,7 +1341,7 @@ Save to `vault/06_system/vault-philosophy.md` - this teaches your agent HOW to u ### The Graph Tools -MOCs and wiki-links create a graph, but the agent needs tooling to traverse it. See `scripts/vault-graph/` for the complete tools: +MOCs and wiki-links create a graph, but the agent needs tooling to traverse it. See [`scripts/vault-graph/`](./scripts/vault-graph/) for the complete tools (committed to this repo — see [`scripts/`](./scripts/)): | Script | Purpose | |--------|---------| diff --git a/part15-infrastructure-hardening.md b/part15-infrastructure-hardening.md index c4193a7..72dbccf 100644 --- a/part15-infrastructure-hardening.md +++ b/part15-infrastructure-hardening.md @@ -375,7 +375,7 @@ git branch -D agent/auth-refactor # if you don't want to keep it ### The 20-Line Spawner -Copy-paste harness for spawning N agents across N worktrees. Pairs with the [Ralph Loop (Part 30)](./part30-ralph-loop-in-openclaw.md) when you want each agent to run autonomously: +Copy-paste harness for spawning N agents across N worktrees. Pairs with the [Ralph Loop (Part 30)](./part30-ralph-loop-in-openclaw.md) when you want each agent to run autonomously. The full script is committed to this repo at [`scripts/fan-out.sh`](./scripts/fan-out.sh): ```bash #!/usr/bin/env bash diff --git a/part30-ralph-loop-in-openclaw.md b/part30-ralph-loop-in-openclaw.md index c3a2118..b951bd0 100644 --- a/part30-ralph-loop-in-openclaw.md +++ b/part30-ralph-loop-in-openclaw.md @@ -137,7 +137,7 @@ The prompt is deliberately boring. Ralph iterations should feel like a factory l ## The Wrapper -A minimal bash wrapper that runs on macOS/Linux. PowerShell port is mechanical. +A minimal bash wrapper that runs on macOS/Linux. PowerShell port is mechanical. The full script is committed to this repo at [`scripts/ralph.sh`](./scripts/ralph.sh). ```bash #!/usr/bin/env bash diff --git a/part9-vault-memory.md b/part9-vault-memory.md index 87e9f87..b0e4e0b 100644 --- a/part9-vault-memory.md +++ b/part9-vault-memory.md @@ -150,6 +150,8 @@ This document is not for you — it's for the agent. You're programming behavior MOCs and wiki-links create a graph, but the agent can't traverse it without tooling. You need two scripts: one that builds the graph, one that searches it. +> The full source for every script in this part is committed to this repo under [`scripts/vault-graph/`](./scripts/vault-graph/) — clone it and run, or copy the blocks below. + #### graph-indexer.mjs This script scans every `.md` file across your vault, memory, and workspace root. It parses `[[wiki-links]]`, resolves them to actual files, and builds a JSON adjacency graph. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..771356f --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,57 @@ +# Scripts + +Runnable companions to the guide. The parts describe what each script does and +why; the source lives here so you can `git clone` once and run them instead of +copy-pasting out of Markdown. + +All scripts are zero-dependency: the `.mjs` tools need only Node.js (ES +modules), and the `.sh` wrappers need only `bash` plus the CLIs called out in +their comments (`openclaw`, `git`, `jq`, `awk`, `bc`). + +## Vault graph tools — [Part 9](../part9-vault-memory.md) + +Run these from your workspace root (or set `OPENCLAW_WORKSPACE` to point at it). + +| Script | Purpose | +|--------|---------| +| [`vault-graph/graph-indexer.mjs`](./vault-graph/graph-indexer.mjs) | Scans every `.md` file, parses `[[wiki-links]]`, writes a JSON adjacency graph to `vault/06_system/graph-index.json`. | +| [`vault-graph/graph-search.mjs`](./vault-graph/graph-search.mjs) | Traverses the graph — finds a file plus its direct and 2nd-degree connections. | +| [`vault-graph/auto-capture.mjs`](./vault-graph/auto-capture.mjs) | Turns an insight into a claim-named note in `vault/00_inbox/` and links it to related MOCs. | +| [`vault-graph/process-inbox.mjs`](./vault-graph/process-inbox.mjs) | Reviews inbox notes and suggests (or moves them to) the right vault folder. | +| [`vault-graph/update-mocs.mjs`](./vault-graph/update-mocs.mjs) | Health check — finds broken wiki-links, stale items, and orphaned notes. | + +```bash +# typical first run +mkdir -p vault/{00_inbox,01_thinking,02_reference,03_creating,04_published,05_archive,06_system} +node scripts/vault-graph/graph-indexer.mjs +node scripts/vault-graph/graph-search.mjs "memory" +``` + +> Note: `vault-graph/auto-capture.mjs` (Part 9) is **not** the same as the +> `auto-capture` hook in [`hooks/auto-capture/`](../hooks/auto-capture/) (Part +> 11). The Part 9 script files a note you hand it; the Part 11 hook extracts +> notes from a session transcript automatically. + +## Autonomy wrappers + +| Script | Part | Purpose | +|--------|------|---------| +| [`ralph.sh`](./ralph.sh) | [Part 30](../part30-ralph-loop-in-openclaw.md) | Runs the Ralph loop — a fresh OpenClaw session per iteration until `PRD.json` is `done` or the iteration / wall-clock / USD budget is exhausted. Needs `PRD.json` and `loop-prompt.md` in the project root. | +| [`fan-out.sh`](./fan-out.sh) | [Part 15](../part15-infrastructure-hardening.md) | Spawns one agent per `*.md` task prompt across N git worktrees, then waits on all of them and reports failures. | + +```bash +scripts/ralph.sh /path/to/project +scripts/fan-out.sh ./tasks +``` + +## Referenced elsewhere (not bundled here) + +A few scripts mentioned in the guide intentionally live outside this repo — +their source isn't reproduced in the Markdown, so committing a copy here would +just go stale. Here's where each one actually comes from: + +| Path in the guide | Part | Where it lives | +|-------------------|------|----------------| +| `scripts/memory-bridge/memory-query.js`, `preflight-context.js` | [Part 13](../part13-memory-bridge.md) | Standalone Memory Bridge tool — copy the two scripts into `scripts/memory-bridge/` in your own workspace as described in Part 13. | +| `lightrag-watcher.py` | [Part 21](../part21-realtime-knowledge-sync.md) | Your real-time sync watcher — Part 21 shows the config block to edit; deploy it under your own `scripts/`. | +| `scripts/qwen_embed_server_v3.py` | [Part 10](../part10-state-of-the-art-embeddings.md) | Ships inside your local OpenClaw install (installed by the one-shot prompt in Part 17), not in this guide repo. | diff --git a/scripts/fan-out.sh b/scripts/fan-out.sh new file mode 100755 index 0000000..a092714 --- /dev/null +++ b/scripts/fan-out.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# scripts/fan-out.sh +set -euo pipefail +tasks_dir="$(realpath "${1:?pass a directory containing one *.md task-prompt per agent}")" +base_repo="$(pwd)" + +mkdir -p "$base_repo/.worktrees" +declare -a pids=() + +for task in "$tasks_dir"/*.md; do + abs_task="$(realpath "$task")" # pin before we cd into the worktree + name=$(basename "$task" .md) + wt="$base_repo/.worktrees/$name" + branch="agent/$name" + + git worktree add "$wt" -b "$branch" >/dev/null + ( + cd "$wt" + openclaw run --prompt-file "$abs_task" --ephemeral --output json \ + > "$base_repo/.worktrees/$name.log" 2>&1 + ) & + pids+=($!) + echo "[fan-out] spawned $name (pid ${pids[-1]}) in $wt" +done + +# Don't let one failing agent orphan the rest: track failures but keep waiting. +set +e +failures=0 +for pid in "${pids[@]}"; do + wait "$pid" || ((failures++)) +done +set -e +echo "[fan-out] all agents done ($failures failed of ${#pids[@]}). Branches: agent/*" +[[ $failures -eq 0 ]] || exit 1 diff --git a/scripts/ralph.sh b/scripts/ralph.sh new file mode 100755 index 0000000..0e6403f --- /dev/null +++ b/scripts/ralph.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# scripts/ralph.sh — run the Ralph loop until PRD is done or budget is exhausted +set -euo pipefail + +PROJECT_ROOT="${1:-$(pwd)}" +cd "$PROJECT_ROOT" + +[ ! -f PRD.json ] && { echo "PRD.json missing"; exit 1; } +[ ! -f loop-prompt.md ] && { echo "loop-prompt.md missing"; exit 1; } + +max_iter=$(jq -r '.budget.max_iterations // 40' PRD.json) +max_usd=$(jq -r '.budget.max_usd // 20' PRD.json) +max_hours=$(jq -r '.budget.max_wall_hours // 4' PRD.json) + +start=$(date +%s) +total_usd=0.00 +i=0 + +while : ; do + i=$((i+1)) + status=$(jq -r '.status' PRD.json) + [ "$status" = "done" ] && { echo "[ralph] PRD.done — exiting at iter $i"; break; } + + elapsed=$(( ($(date +%s) - start) / 3600 )) + [ "$elapsed" -ge "$max_hours" ] && { echo "[ralph] wall-clock budget exhausted"; break; } + [ "$i" -gt "$max_iter" ] && { echo "[ralph] iteration budget exhausted"; break; } + + echo "[ralph] === iter $i (elapsed ${elapsed}h, spent \$$total_usd) ===" + + # Fresh OpenClaw session per iteration. --ephemeral keeps it out of your main history. + out_json=$(openclaw run \ + --prompt-file loop-prompt.md \ + --ephemeral \ + --output json) + + iter_usd=$(echo "$out_json" | jq -r '.usage.cost_usd // 0') + total_usd=$(echo "$total_usd $iter_usd" | awk '{print $1 + $2}') + + (( $(echo "$total_usd >= $max_usd" | bc -l) )) && { echo "[ralph] USD budget exhausted"; break; } + + # Let the dreaming scheduler consolidate anything learned before the next iteration. + sleep 2 +done + +# Final summary. +echo +echo "[ralph] final status=$(jq -r '.status' PRD.json) iterations=$i spent=\$${total_usd}" +jq '.tasks[] | {id, status}' PRD.json diff --git a/scripts/vault-graph/auto-capture.mjs b/scripts/vault-graph/auto-capture.mjs new file mode 100755 index 0000000..2360bbb --- /dev/null +++ b/scripts/vault-graph/auto-capture.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Auto-Capture: Converts insights into claim-named vault notes. + * + * Usage: + * node auto-capture.mjs --claim "nemotron mamba wont train on windows" --body "details..." + * node auto-capture.mjs --file summary.txt + * echo "insight text" | node auto-capture.mjs + */ + +import fs from 'fs'; +import path from 'path'; + +const VAULT = process.env.OPENCLAW_WORKSPACE + ? path.join(process.env.OPENCLAW_WORKSPACE, 'vault') + : path.resolve('vault'); +const INBOX = path.join(VAULT, '00_inbox'); +const THINKING = path.join(VAULT, '01_thinking'); + +function toClaimName(text) { + let claim = text.split(/[.!?\n]/)[0].trim(); + if (claim.length > 80) claim = claim.substring(0, 80); + return claim.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 60) + '.md'; +} + +function findRelatedMOCs(text) { + if (!fs.existsSync(THINKING)) return []; + const mocs = fs.readdirSync(THINKING).filter(f => f.endsWith('.md') && f !== 'README.md'); + const textLower = text.toLowerCase(); + return mocs.filter(moc => { + const keywords = moc.replace('.md', '').split('-').filter(w => w.length > 3); + return keywords.filter(kw => textLower.includes(kw)).length >= 2; + }).map(m => m.replace('.md', '')); +} + +function buildNote(claim, body, relatedMOCs) { + const date = new Date().toISOString().split('T')[0]; + let note = `# ${claim}\n\n## Key Facts\n\n${body}\n\n`; + if (relatedMOCs.length > 0) { + note += `## Connected Topics\n\n`; + for (const moc of relatedMOCs) note += `- [[${moc}]]\n`; + note += `\n`; + } + note += `## Agent Notes\n\n- [ ] Review and verify this capture\n`; + note += `- [ ] Link to additional related notes\n`; + note += `- [ ] Move from inbox to appropriate vault folder\n`; + note += `\n_Captured: ${date}_\n`; + return note; +} + +function updateMOCs(filename, relatedMOCs) { + const linkName = filename.replace('.md', ''); + for (const moc of relatedMOCs) { + const mocPath = path.join(THINKING, moc + '.md'); + if (!fs.existsSync(mocPath)) continue; + let content = fs.readFileSync(mocPath, 'utf8'); + if (!content.includes(`[[${linkName}]]`)) { + const idx = content.indexOf('## Agent Notes'); + if (idx !== -1) { + const insertPoint = content.indexOf('\n', idx) + 1; + content = content.substring(0, insertPoint) + + `\n- [ ] New capture linked: [[${linkName}]]\n` + + content.substring(insertPoint); + fs.writeFileSync(mocPath, content, 'utf8'); + console.log(` Updated MOC: ${moc}.md`); + } + } + } +} + +async function main() { + const args = process.argv.slice(2); + let claim = '', body = ''; + if (args.includes('--claim')) claim = args[args.indexOf('--claim') + 1] || ''; + if (args.includes('--body')) body = args[args.indexOf('--body') + 1] || ''; + if (args.includes('--file')) { + const f = args[args.indexOf('--file') + 1]; + if (f && fs.existsSync(f)) { body = fs.readFileSync(f, 'utf8'); if (!claim) claim = body.split('\n')[0]; } + } + if (!claim && !body) { + console.log('Usage: node auto-capture.mjs --claim "insight" --body "details..."'); + process.exit(1); + } + const filename = toClaimName(claim); + const filepath = path.join(INBOX, filename); + const relatedMOCs = findRelatedMOCs(claim + ' ' + body); + if (!fs.existsSync(INBOX)) fs.mkdirSync(INBOX, { recursive: true }); + fs.writeFileSync(filepath, buildNote(claim, body, relatedMOCs), 'utf8'); + console.log(`✅ Captured: ${filename}`); + if (relatedMOCs.length > 0) { + console.log(` Related MOCs: ${relatedMOCs.join(', ')}`); + updateMOCs(filename, relatedMOCs); + } +} + +main().catch(console.error); diff --git a/scripts/vault-graph/graph-indexer.mjs b/scripts/vault-graph/graph-indexer.mjs new file mode 100755 index 0000000..b498a44 --- /dev/null +++ b/scripts/vault-graph/graph-indexer.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * graph-indexer.mjs — Wiki-Link Graph Indexer for OpenClaw Vault + * + * Scans vault/ (recursive), memory/ (top-level), and root .md files. + * Parses [[wiki-links]], builds adjacency graph, saves JSON index. + * + * Zero npm dependencies. ES module. + * + * Usage: node scripts/vault-graph/graph-indexer.mjs + * Output: vault/06_system/graph-index.json + stats + */ + +import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'; +import { join, basename, relative, extname, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; + +// ── Configuration (edit these to match your workspace) ───────────────────── + +const WORKSPACE = resolve(process.env.OPENCLAW_WORKSPACE || '.'); +const VAULT_DIR = join(WORKSPACE, 'vault'); +const MEMORY_DIR = join(WORKSPACE, 'memory'); +const OUTPUT_FILE = join(VAULT_DIR, '06_system', 'graph-index.json'); + +const ROOT_FILES = [ + 'MEMORY.md', 'SOUL.md', 'AGENTS.md', 'TOOLS.md', 'USER.md', 'IDENTITY.md' +].map(f => join(WORKSPACE, f)); + +const WIKI_LINK_RE = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g; + +// ── File Discovery ───────────────────────────────────────────────────────── + +async function collectMdFilesRecursive(dir) { + const results = []; + if (!existsSync(dir)) return results; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name.startsWith('.')) continue; + results.push(...await collectMdFilesRecursive(fullPath)); + } else if (entry.isFile() && extname(entry.name).toLowerCase() === '.md') { + results.push(fullPath); + } + } + return results; +} + +async function collectMemoryTopLevel() { + const results = []; + if (!existsSync(MEMORY_DIR)) return results; + const entries = await readdir(MEMORY_DIR, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && extname(entry.name).toLowerCase() === '.md') { + results.push(join(MEMORY_DIR, entry.name)); + } + } + return results; +} + +async function discoverFiles() { + const [vaultFiles, memoryFiles] = await Promise.all([ + collectMdFilesRecursive(VAULT_DIR), + collectMemoryTopLevel() + ]); + const rootFiles = ROOT_FILES.filter(f => existsSync(f)); + return [...vaultFiles, ...memoryFiles, ...rootFiles]; +} + +// ── Parsing ──────────────────────────────────────────────────────────────── + +function extractTitle(content, filePath) { + const match = content.match(/^#{1,6}\s+(.+)$/m); + return match ? match[1].trim() : basename(filePath, '.md'); +} + +function extractWikiLinks(content) { + const links = new Set(); + let match; + WIKI_LINK_RE.lastIndex = 0; + while ((match = WIKI_LINK_RE.exec(content)) !== null) { + const target = match[1].trim(); + if (target) links.add(target); + } + return [...links]; +} + +// ── Link Resolution ──────────────────────────────────────────────────────── + +function buildLookupMap(filePaths) { + const lookup = new Map(); + for (const fp of filePaths) { + const rel = relative(WORKSPACE, fp).replace(/\\/g, '/'); + const name = basename(fp, '.md'); + const keys = [ + name.toLowerCase(), + basename(fp).toLowerCase(), + rel.toLowerCase(), + rel.replace(/\.md$/i, '').toLowerCase(), + ]; + for (const key of keys) { + if (!lookup.has(key)) lookup.set(key, []); + lookup.get(key).push(fp); + } + } + return lookup; +} + +function resolveLink(rawTarget, lookupMap) { + const normalized = rawTarget.replace(/\\/g, '/').trim().toLowerCase(); + const withoutExt = normalized.replace(/\.md$/i, ''); + for (const key of [withoutExt, normalized, withoutExt + '.md']) { + const matches = lookupMap.get(key); + if (matches?.length > 0) return matches[0]; + } + for (const [key, paths] of lookupMap.entries()) { + if (key.endsWith('/' + withoutExt) || key.endsWith('/' + normalized)) { + return paths[0]; + } + } + return null; +} + +// ── Graph Building ───────────────────────────────────────────────────────── + +async function buildGraph(filePaths) { + const lookupMap = buildLookupMap(filePaths); + const graph = {}; + + for (const fp of filePaths) { + const key = relative(WORKSPACE, fp).replace(/\\/g, '/'); + graph[key] = { linksTo: [], linkedFrom: [], title: '', lastModified: '', path: fp.replace(/\\/g, '/') }; + } + + for (const fp of filePaths) { + const key = relative(WORKSPACE, fp).replace(/\\/g, '/'); + try { + const content = await readFile(fp, 'utf-8'); + const fileStat = await stat(fp); + graph[key].title = extractTitle(content, fp); + graph[key].lastModified = fileStat.mtime.toISOString(); + + for (const rawLink of extractWikiLinks(content)) { + const resolvedPath = resolveLink(rawLink, lookupMap); + if (resolvedPath) { + const targetKey = relative(WORKSPACE, resolvedPath).replace(/\\/g, '/'); + if (!graph[key].linksTo.includes(targetKey)) graph[key].linksTo.push(targetKey); + if (graph[targetKey] && !graph[targetKey].linkedFrom.includes(key)) + graph[targetKey].linkedFrom.push(key); + } + } + } catch (err) { + console.error(`⚠ Error processing ${fp}: ${err.message}`); + } + } + return graph; +} + +// ── Stats ────────────────────────────────────────────────────────────────── + +function printStats(graph) { + const entries = Object.entries(graph); + let totalLinks = 0; + for (const [, node] of entries) totalLinks += node.linksTo.length; + + const connectivity = entries.map(([key, node]) => ({ + key, title: node.title, + total: node.linksTo.length + node.linkedFrom.length + })).sort((a, b) => b.total - a.total); + + const orphans = connectivity.filter(n => n.total === 0); + + console.log(`\n📊 Indexed: ${entries.length} files | ${totalLinks} wiki-links | ${entries.length - orphans.length} connected | ${orphans.length} orphans\n`); + console.log('Top 10 most connected:'); + for (const n of connectivity.slice(0, 10)) { + if (n.total === 0) break; + console.log(` ${n.total.toString().padStart(3)} links │ ${n.title}`); + } +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +async function main() { + const filePaths = await discoverFiles(); + console.log(`🔍 Found ${filePaths.length} markdown files`); + const graph = await buildGraph(filePaths); + await mkdir(join(VAULT_DIR, '06_system'), { recursive: true }); + await writeFile(OUTPUT_FILE, JSON.stringify(graph, null, 2), 'utf-8'); + console.log(`💾 Saved to vault/06_system/graph-index.json`); + printStats(graph); +} + +main().catch(err => { console.error('❌', err); process.exit(1); }); diff --git a/scripts/vault-graph/graph-search.mjs b/scripts/vault-graph/graph-search.mjs new file mode 100755 index 0000000..e0fe108 --- /dev/null +++ b/scripts/vault-graph/graph-search.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * graph-search.mjs — Traverse the wiki-link graph + * + * Usage: node scripts/vault-graph/graph-search.mjs "search term" + */ + +import { readFile } from 'node:fs/promises'; +import { resolve, basename } from 'node:path'; +import { existsSync } from 'node:fs'; + +const WORKSPACE = resolve(process.env.OPENCLAW_WORKSPACE || '.'); +const INDEX_FILE = resolve(WORKSPACE, 'vault/06_system/graph-index.json'); + +async function loadGraph() { + if (!existsSync(INDEX_FILE)) { + console.error('❌ Run graph-indexer.mjs first'); + process.exit(1); + } + return JSON.parse(await readFile(INDEX_FILE, 'utf-8')); +} + +function findMatches(graph, term) { + const t = term.toLowerCase(); + const matches = []; + for (const [key, node] of Object.entries(graph)) { + const keyL = key.toLowerCase(), nameL = basename(key, '.md').toLowerCase(); + const titleL = (node.title || '').toLowerCase(); + let score = 0; + if (keyL === t || keyL === t + '.md') score = 100; + else if (nameL === t) score = 90; + else if (keyL.includes(t)) score = 60; + else if (titleL.includes(t)) score = 40; + if (score) matches.push({ key, node, score }); + } + return matches.sort((a, b) => b.score - a.score); +} + +function getSecondDegree(graph, nodeKey, directKeys) { + const second = new Map(); + const skip = new Set([nodeKey, ...directKeys]); + for (const dk of directKeys) { + const dn = graph[dk]; + if (!dn) continue; + for (const t of [...dn.linksTo, ...dn.linkedFrom]) { + if (!skip.has(t)) second.set(t, dk); + } + } + return second; +} + +async function main() { + const term = process.argv[2]; + if (!term) { console.log('Usage: node graph-search.mjs "term"'); process.exit(0); } + const graph = await loadGraph(); + const matches = findMatches(graph, term); + + if (!matches.length) { console.log(`No matches for "${term}"`); return; } + + for (const { key, node } of matches.slice(0, 5)) { + console.log(`\n📄 ${node.title || key}`); + console.log(` ${key}`); + if (node.linksTo.length) { + console.log(` 📤 Links to:`); + for (const t of node.linksTo) console.log(` → ${graph[t]?.title || t}`); + } + if (node.linkedFrom.length) { + console.log(` 📥 Linked from:`); + for (const s of node.linkedFrom) console.log(` ← ${graph[s]?.title || s}`); + } + const directKeys = [...node.linksTo, ...node.linkedFrom]; + const second = getSecondDegree(graph, key, directKeys); + if (second.size) { + console.log(` 🌐 2nd degree (${second.size}):`); + for (const [sk, via] of [...second.entries()].slice(0, 10)) { + console.log(` ↔ ${graph[sk]?.title || sk} (via ${graph[via]?.title || via})`); + } + } + } +} + +main().catch(console.error); diff --git a/scripts/vault-graph/process-inbox.mjs b/scripts/vault-graph/process-inbox.mjs new file mode 100755 index 0000000..6019251 --- /dev/null +++ b/scripts/vault-graph/process-inbox.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Process Inbox: Scans vault/00_inbox/ and suggests filing locations. + * + * Usage: + * node process-inbox.mjs # report only + * node process-inbox.mjs --auto # auto-move files + */ + +import fs from 'fs'; +import path from 'path'; + +const VAULT = process.env.OPENCLAW_WORKSPACE + ? path.join(process.env.OPENCLAW_WORKSPACE, 'vault') + : path.resolve('vault'); +const INBOX = path.join(VAULT, '00_inbox'); + +function classifyNote(filename, content) { + const lower = content.toLowerCase(); + const wikiLinks = (content.match(/\[\[/g) || []).length; + if (wikiLinks >= 3 || lower.includes('## agent notes')) return '01_thinking'; + if (lower.includes('api') || lower.includes('documentation') || lower.includes('reference')) return '02_reference'; + if (lower.includes('draft') || lower.includes('script') || lower.includes('outline')) return '03_creating'; + return '01_thinking'; +} + +function main() { + const autoMove = process.argv.includes('--auto'); + if (!fs.existsSync(INBOX)) { console.log('📭 Inbox empty.'); return; } + const files = fs.readdirSync(INBOX).filter(f => f.endsWith('.md') && f !== 'README.md'); + if (!files.length) { console.log('📭 Inbox empty.'); return; } + + console.log(`📬 ${files.length} notes to process:\n`); + for (const file of files) { + const content = fs.readFileSync(path.join(INBOX, file), 'utf8'); + const dest = classifyNote(file, content); + console.log(` ${file} → vault/${dest}/`); + if (autoMove) { + const destDir = path.join(VAULT, dest); + if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true }); + fs.renameSync(path.join(INBOX, file), path.join(destDir, file)); + console.log(` ✅ Moved!`); + } + } + if (!autoMove && files.length) console.log(`\nRun with --auto to move files.`); +} + +main(); diff --git a/scripts/vault-graph/update-mocs.mjs b/scripts/vault-graph/update-mocs.mjs new file mode 100755 index 0000000..3f26ae5 --- /dev/null +++ b/scripts/vault-graph/update-mocs.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * MOC Health Check: Validates wiki-links and finds stale items. + * + * Usage: node update-mocs.mjs + */ + +import fs from 'fs'; +import path from 'path'; + +const WORKSPACE = process.env.OPENCLAW_WORKSPACE || path.resolve('.'); +const VAULT = path.join(WORKSPACE, 'vault'); +const MEMORY = path.join(WORKSPACE, 'memory'); +const THINKING = path.join(VAULT, '01_thinking'); + +function collectFiles(dir, recursive = true) { + const results = []; + if (!fs.existsSync(dir)) return results; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory() && recursive) results.push(...collectFiles(full)); + else if (entry.name.endsWith('.md')) results.push(full); + } + return results; +} + +function buildFileIndex() { + const index = new Map(); + const allFiles = [ + ...collectFiles(VAULT), + ...collectFiles(MEMORY, false), + ...['MEMORY.md', 'SOUL.md', 'AGENTS.md', 'TOOLS.md', 'USER.md', 'IDENTITY.md'] + .map(f => path.join(WORKSPACE, f)).filter(f => fs.existsSync(f)), + ]; + for (const f of allFiles) { + const bn = path.basename(f, '.md').toLowerCase(); + const rel = path.relative(WORKSPACE, f).replace(/\\/g, '/'); + index.set(bn, f); + index.set(rel.toLowerCase(), f); + index.set(rel.replace(/\.md$/i, '').toLowerCase(), f); + } + return index; +} + +function main() { + if (!fs.existsSync(THINKING)) { console.log('No MOCs found.'); return; } + const fileIndex = buildFileIndex(); + const mocs = fs.readdirSync(THINKING).filter(f => f.endsWith('.md') && f !== 'README.md'); + + console.log(`🔍 Checking ${mocs.length} MOCs\n`); + let totalLinks = 0, brokenLinks = 0; + + for (const moc of mocs) { + const content = fs.readFileSync(path.join(THINKING, moc), 'utf8'); + const links = [...content.matchAll(/\[\[([^\]|]+)/g)].map(m => m[1].trim()); + const broken = links.filter(l => { + const k = l.toLowerCase().replace(/\.md$/i, ''); + return !fileIndex.has(k) && !fileIndex.has(k.split('/').pop()); + }); + totalLinks += links.length; + brokenLinks += broken.length; + if (broken.length) { + console.log(`📄 ${moc}`); + for (const b of broken) console.log(` ❌ [[${b}]] → not found`); + } + } + console.log(`\n${mocs.length} MOCs | ${totalLinks} links | ${brokenLinks} broken`); +} + +main();