/**
* Per-vault configuration loading and validation (Phase 017)
*
* Phase 017 simplifies the structure configuration:
* - No more knowledge type to path mappings
* - Only special folder locations are configurable
* - Domain/topic directly becomes the folder path
*/
import { z } from 'zod';
import { readFileSync, existsSync } from 'fs';
import { join, basename } from 'path';
import { parse as parseYaml } from 'yaml';
import { logger } from '../utils/logger.js';
import type { VaultConfig, VaultAccessMode } from '../types/index.js';
// Zod schema for simplified structure (Phase 017)
const vaultStructureSchema = z.object({
sources: z.string().optional().default('sources/'),
projects: z.string().optional().default('projects/'),
clients: z.string().optional().default('clients/'),
daily: z.string().optional().default('daily/'),
standards: z.string().optional().default('standards/'),
});
// Zod schema for ignore config
const ignoreConfigSchema = z.object({
patterns: z.array(z.string()).default(['.obsidian/', 'templates/']),
marker_file: z.string().default('.palace-ignore'),
frontmatter_key: z.string().default('palace_ignore'),
});
// Zod schema for atomic config
// Phase 018: Removed hub_filename - hub names are now derived from title
// Phase 022: Added min_section_lines, max_children, and hub_sections
const atomicConfigSchema = z.object({
max_lines: z.number().default(200),
max_sections: z.number().default(6),
section_max_lines: z.number().optional(),
auto_split: z.boolean().default(true),
// Phase 022: New configurable thresholds
min_section_lines: z.number().optional(), // Minimum lines for section to be split (default: 5)
max_children: z.number().optional(), // Maximum children to create (default: 10)
// Phase 022: Sections that stay in hub during splits
hub_sections: z.array(z.string()).optional(), // e.g., ['Quick Reference', 'Summary']
});
// Zod schema for stub config
const stubConfigSchema = z.object({
auto_create: z.boolean().default(true),
min_confidence: z.number().min(0).max(1).default(0.2),
});
// Zod schema for graph config
const graphConfigSchema = z.object({
require_technology_links: z.boolean().default(false), // Phase 017: Default to false
warn_orphan_depth: z.number().default(1),
retroactive_linking: z.boolean().default(true),
});
// Zod schema for autolink config (Phase 024)
const autolinkConfigSchema = z.object({
link_mode: z.enum(['all', 'first_per_section', 'first_per_note']).default('first_per_section'),
stop_words: z.array(z.string()).optional(), // Additional stop words (merged with defaults)
stop_words_override: z.array(z.string()).optional(), // Complete replacement of stop words
domain_scope: z.union([
z.literal('any'),
z.literal('same_domain'),
z.array(z.string()), // Specific domains to allow
]).default('any'),
min_title_length: z.number().min(1).max(50).default(3),
max_links_per_paragraph: z.number().optional(), // Limit link density
min_word_distance: z.number().optional(), // Minimum words between links
});
// Zod schema for history config (Phase 028)
const historyConfigSchema = z.object({
enabled: z.boolean().default(true),
max_versions_per_note: z.number().min(1).max(1000).default(50),
max_age_days: z.number().min(1).max(3650).default(90),
auto_cleanup: z.boolean().default(true),
exclude_patterns: z.array(z.string()).default(['daily/**']),
});
// Zod schema for vault info
const vaultInfoSchema = z.object({
name: z.string(),
description: z.string().optional(),
mode: z.enum(['rw', 'ro']).optional(),
});
// Zod schema for complete vault config (Phase 017, updated Phase 024, Phase 028)
const vaultConfigSchema = z.object({
vault: vaultInfoSchema,
structure: vaultStructureSchema.default({}),
ignore: ignoreConfigSchema.default({}),
atomic: atomicConfigSchema.default({}),
stubs: stubConfigSchema.default({}),
graph: graphConfigSchema.default({}),
autolink: autolinkConfigSchema.default({}), // Phase 024
history: historyConfigSchema.default({}), // Phase 028
});
/**
* Create default vault config for a path (Phase 017)
*/
export function createDefaultVaultConfig(
vaultPath: string,
mode: VaultAccessMode = 'rw'
): VaultConfig {
const name = basename(vaultPath);
return {
vault: {
name,
mode,
},
structure: {
sources: 'sources/',
projects: 'projects/',
clients: 'clients/',
daily: 'daily/',
standards: 'standards/',
},
ignore: {
patterns: ['.obsidian/', 'templates/', 'private/**'],
marker_file: '.palace-ignore',
frontmatter_key: 'palace_ignore',
},
atomic: {
max_lines: 200,
max_sections: 6,
auto_split: true,
},
stubs: {
auto_create: true,
min_confidence: 0.2,
},
graph: {
require_technology_links: false,
warn_orphan_depth: 1,
retroactive_linking: true,
},
autolink: {
link_mode: 'first_per_section',
domain_scope: 'any',
min_title_length: 3,
},
history: {
enabled: true,
max_versions_per_note: 50,
max_age_days: 90,
auto_cleanup: true,
exclude_patterns: ['daily/**'],
},
};
}
/**
* Load vault configuration from .palace.yaml
*/
export function loadVaultConfig(
vaultPath: string,
defaultMode: VaultAccessMode = 'rw'
): VaultConfig {
const configPath = join(vaultPath, '.palace.yaml');
if (!existsSync(configPath)) {
logger.debug(`No .palace.yaml found in ${vaultPath}, using defaults`);
return createDefaultVaultConfig(vaultPath, defaultMode);
}
try {
const content = readFileSync(configPath, 'utf-8');
const rawConfig = parseYaml(content);
const result = vaultConfigSchema.safeParse(rawConfig);
if (!result.success) {
const errors = result.error.issues
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
.join('\n');
logger.warn(`Invalid vault config at ${configPath}:\n${errors}`);
logger.warn('Using default configuration');
return createDefaultVaultConfig(vaultPath, defaultMode);
}
logger.debug(`Loaded vault config from ${configPath}`);
// Merge with defaults to ensure all fields exist
const parsed = result.data;
const defaults = createDefaultVaultConfig(vaultPath, defaultMode);
const mergedConfig: VaultConfig = {
vault: {
name: parsed.vault.name ?? defaults.vault.name,
description: parsed.vault.description ?? defaults.vault.description,
mode: parsed.vault.mode ?? defaults.vault.mode,
},
structure: {
sources: parsed.structure.sources ?? defaults.structure.sources,
projects: parsed.structure.projects ?? defaults.structure.projects,
clients: parsed.structure.clients ?? defaults.structure.clients,
daily: parsed.structure.daily ?? defaults.structure.daily,
standards: parsed.structure.standards ?? defaults.structure.standards,
},
ignore: {
patterns: parsed.ignore.patterns ?? defaults.ignore.patterns,
marker_file: parsed.ignore.marker_file ?? defaults.ignore.marker_file,
frontmatter_key:
parsed.ignore.frontmatter_key ?? defaults.ignore.frontmatter_key,
},
atomic: {
max_lines: parsed.atomic.max_lines ?? defaults.atomic.max_lines,
max_sections:
parsed.atomic.max_sections ?? defaults.atomic.max_sections,
section_max_lines:
parsed.atomic.section_max_lines ?? defaults.atomic.section_max_lines,
auto_split: parsed.atomic.auto_split ?? defaults.atomic.auto_split,
},
stubs: {
auto_create: parsed.stubs.auto_create ?? defaults.stubs.auto_create,
min_confidence:
parsed.stubs.min_confidence ?? defaults.stubs.min_confidence,
},
graph: {
require_technology_links:
parsed.graph.require_technology_links ??
defaults.graph.require_technology_links,
warn_orphan_depth:
parsed.graph.warn_orphan_depth ?? defaults.graph.warn_orphan_depth,
retroactive_linking:
parsed.graph.retroactive_linking ?? defaults.graph.retroactive_linking,
},
autolink: {
link_mode: parsed.autolink?.link_mode ?? defaults.autolink.link_mode,
stop_words: parsed.autolink?.stop_words,
stop_words_override: parsed.autolink?.stop_words_override,
domain_scope: parsed.autolink?.domain_scope ?? defaults.autolink.domain_scope,
min_title_length: parsed.autolink?.min_title_length ?? defaults.autolink.min_title_length,
max_links_per_paragraph: parsed.autolink?.max_links_per_paragraph,
min_word_distance: parsed.autolink?.min_word_distance,
},
history: {
enabled: parsed.history?.enabled ?? defaults.history.enabled,
max_versions_per_note: parsed.history?.max_versions_per_note ?? defaults.history.max_versions_per_note,
max_age_days: parsed.history?.max_age_days ?? defaults.history.max_age_days,
auto_cleanup: parsed.history?.auto_cleanup ?? defaults.history.auto_cleanup,
exclude_patterns: parsed.history?.exclude_patterns ?? defaults.history.exclude_patterns,
},
};
return mergedConfig;
} catch (error) {
logger.error(`Failed to load vault config from ${configPath}`, error);
return createDefaultVaultConfig(vaultPath, defaultMode);
}
}
/**
* Check if a path is in the standards folder
*/
export function isStandardsPath(config: VaultConfig, path: string): boolean {
const standardsFolder = config.structure.standards || 'standards/';
return path.startsWith(standardsFolder.replace(/\/$/, ''));
}
/**
* Get ai_binding for a standard note (always 'required' for standards)
*/
export function getAiBinding(
config: VaultConfig,
path: string
): 'required' | 'recommended' | 'optional' | undefined {
if (isStandardsPath(config, path)) {
return 'required';
}
return undefined;
}
// Export schemas for testing
export const schemas = {
vaultStructure: vaultStructureSchema,
ignoreConfig: ignoreConfigSchema,
atomicConfig: atomicConfigSchema,
stubConfig: stubConfigSchema,
graphConfig: graphConfigSchema,
historyConfig: historyConfigSchema,
autolinkConfig: autolinkConfigSchema,
vaultInfo: vaultInfoSchema,
vaultConfig: vaultConfigSchema,
};