302 lines
11 KiB
JavaScript
302 lines
11 KiB
JavaScript
// bun scripts/sync-content.mjs
|
|
//
|
|
// Copies the pieces of the Beaver stack that carry no personal data into
|
|
// src/content/: prompt granules and skills from the vault, config modules and
|
|
// examples from beaver-agent and beaver-gateway. Every source is listed here by
|
|
// hand and copied verbatim - nothing is rewritten on the way out.
|
|
//
|
|
// A file that would need rewriting to be publishable is NOT synced. Its
|
|
// approximate, hand-written counterpart lives at its real vault path inside
|
|
// src/content/{ru,en}/vault/, says what kind of thing belongs there, and is
|
|
// maintained by hand. See README.
|
|
//
|
|
// The scan below is the backstop, not the mechanism: it aborts the sync when a
|
|
// name from `👤 люди/`, a handle, an id or a host reaches src/content/. Neither
|
|
// list lives in this repo: names are read from the vault at run time, the rest
|
|
// from a file outside the checkout (BEAVER_FORBIDDEN, default
|
|
// ~/.config/beaver-land/forbidden.txt).
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
|
|
const VAULT = process.env.BEAVER_VAULT ?? `${process.env.HOME}/Documents/obsidian/hh`;
|
|
const ZONE = join(VAULT, 'мета/бобер');
|
|
const REPOS = resolve(import.meta.dirname, '../..');
|
|
const OUT = resolve(import.meta.dirname, '../src/content');
|
|
const SYNCED = join(OUT, 'synced');
|
|
const FORBIDDEN_FILE =
|
|
process.env.BEAVER_FORBIDDEN ?? `${process.env.HOME}/.config/beaver-land/forbidden.txt`;
|
|
|
|
// [source, target under src/content]
|
|
const FILES = [
|
|
// voice + quick overlay: the Raycast prompt is assembled from these
|
|
['vault', 'промпты/гранулы/голос/role.md', 'prompts/voice/role.md'],
|
|
['vault', 'промпты/гранулы/голос/philosophy.md', 'prompts/voice/philosophy.md'],
|
|
['vault', 'промпты/гранулы/голос/user_profile.md', 'prompts/voice/user_profile.md'],
|
|
['vault', 'промпты/гранулы/голос/operating_modes.md', 'prompts/voice/operating_modes.md'],
|
|
['vault', 'промпты/гранулы/голос/how_you_operate.md', 'prompts/voice/how_you_operate.md'],
|
|
[
|
|
'vault',
|
|
'промпты/гранулы/голос/interaction_guidelines.md',
|
|
'prompts/voice/interaction_guidelines.md'
|
|
],
|
|
['vault', 'промпты/гранулы/быстрый.md', 'prompts/overlays/быстрый.md'],
|
|
// overlays and environments
|
|
['vault', 'промпты/гранулы/ветка.md', 'prompts/overlays/ветка.md'],
|
|
['vault', 'промпты/гранулы/глубокий.md', 'prompts/overlays/глубокий.md'],
|
|
['vault', 'промпты/гранулы/триаж.md', 'prompts/overlays/триаж.md'],
|
|
['vault', 'промпты/гранулы/хендаут.md', 'prompts/overlays/хендаут.md'],
|
|
['vault', 'промпты/гранулы/слив.md', 'prompts/overlays/слив.md'],
|
|
['vault', 'промпты/гранулы/карта-vault.md', 'prompts/карта-vault.md'],
|
|
['vault', 'промпты/гранулы/окружения/мастер.md', 'prompts/environments/мастер.md'],
|
|
['vault', 'промпты/гранулы/окружения/ветка.md', 'prompts/environments/ветка.md'],
|
|
['vault', 'промпты/гранулы/окружения/глубокий.md', 'prompts/environments/глубокий.md'],
|
|
['vault', 'промпты/гранулы/окружения/джоб.md', 'prompts/environments/джоб.md'],
|
|
// skills
|
|
['vault', 'скиллы/общие/чаты/SKILL.md', 'skills/общие/чаты/SKILL.md'],
|
|
['vault', 'скиллы/vault/графики/SKILL.md', 'skills/vault/графики/SKILL.md'],
|
|
// beaver-agent: the real setup
|
|
['repo', 'beaver-agent/config.py', 'code/beaver-agent/config.py'],
|
|
['repo', 'beaver-agent/beaver_agent/vault.py', 'code/beaver-agent/beaver_agent/vault.py'],
|
|
['repo', 'beaver-agent/beaver_agent/prompts.py', 'code/beaver-agent/beaver_agent/prompts.py'],
|
|
['repo', 'beaver-agent/beaver_agent/skills.py', 'code/beaver-agent/beaver_agent/skills.py'],
|
|
['repo', 'beaver-agent/beaver_agent/agents.py', 'code/beaver-agent/beaver_agent/agents.py'],
|
|
['repo', 'beaver-agent/beaver_agent/frontends.py', 'code/beaver-agent/beaver_agent/frontends.py'],
|
|
['repo', 'beaver-agent/beaver_agent/policy.py', 'code/beaver-agent/beaver_agent/policy.py'],
|
|
['repo', 'beaver-agent/beaver_agent/texts.py', 'code/beaver-agent/beaver_agent/texts.py'],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/hands/__init__.py',
|
|
'code/beaver-agent/beaver_agent/hands/__init__.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/hands/komodo.py',
|
|
'code/beaver-agent/beaver_agent/hands/komodo.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/hands/homeassistant.py',
|
|
'code/beaver-agent/beaver_agent/hands/homeassistant.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/hands/calendars.py',
|
|
'code/beaver-agent/beaver_agent/hands/calendars.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/memory/watch.py',
|
|
'code/beaver-agent/beaver_agent/memory/watch.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/memory/recall.py',
|
|
'code/beaver-agent/beaver_agent/memory/recall.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/memory/handouts.py',
|
|
'code/beaver-agent/beaver_agent/memory/handouts.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/memory/curator.py',
|
|
'code/beaver-agent/beaver_agent/memory/curator.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/jobs/__init__.py',
|
|
'code/beaver-agent/beaver_agent/jobs/__init__.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/jobs/rotation.py',
|
|
'code/beaver-agent/beaver_agent/jobs/rotation.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/jobs/closing.py',
|
|
'code/beaver-agent/beaver_agent/jobs/closing.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/jobs/memory.py',
|
|
'code/beaver-agent/beaver_agent/jobs/memory.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/jobs/deploy.py',
|
|
'code/beaver-agent/beaver_agent/jobs/deploy.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/jobs/t3code.py',
|
|
'code/beaver-agent/beaver_agent/jobs/t3code.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-agent/beaver_agent/jobs/vibegram.py',
|
|
'code/beaver-agent/beaver_agent/jobs/vibegram.py'
|
|
],
|
|
['repo', 'beaver-agent/.env.example', 'code/beaver-agent/.env.example'],
|
|
['repo', 'beaver-agent/caddy/site.caddy', 'code/beaver-agent/caddy/site.caddy'],
|
|
['repo', 'beaver-agent/Makefile', 'code/beaver-agent/Makefile'],
|
|
// beaver-gateway: primitives
|
|
['repo', 'beaver-gateway/examples/config.py', 'code/beaver-gateway/examples/config.py'],
|
|
[
|
|
'repo',
|
|
'beaver-gateway/examples/docker-compose.yml',
|
|
'code/beaver-gateway/examples/docker-compose.yml'
|
|
],
|
|
['repo', 'beaver-gateway/src/beaver_gateway/config.py', 'code/beaver-gateway/config.py'],
|
|
[
|
|
'repo',
|
|
'beaver-gateway/src/beaver_gateway/agents/prompts.py',
|
|
'code/beaver-gateway/agents/prompts.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-gateway/src/beaver_gateway/conversations/rotation.py',
|
|
'code/beaver-gateway/conversations/rotation.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-gateway/src/beaver_gateway/conversations/injects.py',
|
|
'code/beaver-gateway/conversations/injects.py'
|
|
],
|
|
[
|
|
'repo',
|
|
'beaver-gateway/src/beaver_gateway/conversations/distill.py',
|
|
'code/beaver-gateway/conversations/distill.py'
|
|
],
|
|
['repo', 'beaver-gateway/src/beaver_gateway/jobs/job.py', 'code/beaver-gateway/jobs/job.py'],
|
|
[
|
|
'repo',
|
|
'beaver-gateway/src/beaver_gateway/agents/policy.py',
|
|
'code/beaver-gateway/agents/policy.py'
|
|
],
|
|
['repo', 'beaver-gateway/src/beaver_gateway/mcp/types.py', 'code/beaver-gateway/mcp/types.py'],
|
|
['repo', 'beaver-plugin-obsidian/src/settings.ts', 'code/beaver-plugin-obsidian/settings.ts']
|
|
];
|
|
|
|
// Anything the scan finds in an output file aborts the sync. Names come from
|
|
// the vault at run time (the first name of every card and alias in `👤 люди/`);
|
|
// hosts, ids and nicknames come from FORBIDDEN_FILE. Without that file the
|
|
// backstop is blind, so its absence is an error, not a warning.
|
|
function forbiddenStatic() {
|
|
if (!existsSync(FORBIDDEN_FILE)) {
|
|
throw new Error(`sync: ${FORBIDDEN_FILE} is missing (BEAVER_FORBIDDEN overrides the path)`);
|
|
}
|
|
return readFileSync(FORBIDDEN_FILE, 'utf8')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => line && !line.startsWith('#'));
|
|
}
|
|
|
|
function firstName(text) {
|
|
const [head] = text.split(/[^\p{L}]+/u).filter(Boolean);
|
|
return head && head.length >= 4 && /^\p{Lu}/u.test(head) ? [head] : [];
|
|
}
|
|
|
|
function peopleNames() {
|
|
const dir = join(VAULT, '\u{1F464} \u043B\u044E\u0434\u0438');
|
|
const out = new Set();
|
|
const walk = (path) => {
|
|
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
const child = join(path, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(child);
|
|
continue;
|
|
}
|
|
if (!entry.name.endsWith('.md')) continue;
|
|
const stem = entry.name.slice(0, -3);
|
|
if (stem === basename(path)) continue;
|
|
for (const part of firstName(stem)) out.add(part);
|
|
const head = readFileSync(child, 'utf8').slice(0, 2000);
|
|
for (const handle of head.match(/@[A-Za-z0-9_]{4,}/g) ?? []) out.add(handle);
|
|
for (const mail of head.match(/[\w.+-]+@[\w-]+\.[a-z]{2,}/gi) ?? []) out.add(mail);
|
|
const aliases = head.match(/^aliases:\s*(.*)$/m);
|
|
if (!aliases) continue;
|
|
const inline = aliases[1].trim().replace(/^\[|\]$/g, '');
|
|
const items = inline
|
|
? inline.split(',')
|
|
: head
|
|
.split(/^aliases:/m)[1]
|
|
.split(/\n(?!\s*-)/)[0]
|
|
.split('\n');
|
|
for (const item of items) {
|
|
const name = item
|
|
.replace(/^\s*-\s*/, '')
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '');
|
|
if (name.includes(':')) continue;
|
|
for (const part of firstName(name)) out.add(part);
|
|
}
|
|
}
|
|
};
|
|
try {
|
|
walk(dir);
|
|
} catch {
|
|
console.warn('sync: no people directory, name scan limited to FORBIDDEN_FILE');
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
const FORBIDDEN = [...forbiddenStatic(), ...peopleNames()];
|
|
|
|
function read(kind, rel) {
|
|
const base = kind === 'vault' ? ZONE : REPOS;
|
|
return readFileSync(join(base, rel), 'utf8');
|
|
}
|
|
|
|
function check(target, text) {
|
|
const hits = FORBIDDEN.filter((word) =>
|
|
new RegExp(`(?<!\\p{L})${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\p{L})`, 'u').test(
|
|
text
|
|
)
|
|
);
|
|
if (hits.length) throw new Error(`${target}: forbidden strings ${hits.join(', ')}`);
|
|
}
|
|
|
|
rmSync(SYNCED, { recursive: true, force: true });
|
|
|
|
let count = 0;
|
|
for (const [kind, rel, target] of FILES) {
|
|
const text = read(kind, rel);
|
|
check(target, text);
|
|
const path = join(SYNCED, target);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, text);
|
|
count += 1;
|
|
}
|
|
|
|
// The hand-written trees (the mock vaults with their approximations, the
|
|
// translations) are never rewritten here - only scanned, so a name cannot creep
|
|
// in by hand either.
|
|
function scan(dir) {
|
|
let checked = 0;
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
checked += scan(path);
|
|
continue;
|
|
}
|
|
if (!/\.(md|ya?ml|py|ts|caddy|example)$/.test(entry.name) && entry.name !== 'Makefile')
|
|
continue;
|
|
check(path.slice(OUT.length + 1), readFileSync(path, 'utf8'));
|
|
checked += 1;
|
|
}
|
|
return checked;
|
|
}
|
|
|
|
let scanned = 0;
|
|
for (const dir of ['ru', 'en']) {
|
|
try {
|
|
scanned += scan(join(OUT, dir));
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
|
|
console.log(`synced ${count} files into ${SYNCED}, scanned ${scanned} hand-written ones`);
|