/** * Config schema definitions using Zod. * Defines Collection, Context, and Config types for GNO. * * @module src/config/types */ // node:path provides platform-correct absolute-path validation; Bun has no path utilities. import { isAbsolute } from "node:path"; import { z } from "zod"; import { URI_PREFIX } from "../app/constants"; import { JsonlFieldMappingSchema } from "../converters/adapters/jsonl/config"; import { MCP_TOOL_PROFILES } from "../mcp/tool-profile"; import { ChunkingConfigSchema, type ChunkingParams } from "./chunking"; import { RetrievalTraceConfigSchema } from "./retrieval-traces"; // ───────────────────────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────────────────────── /** Current config version */ export const CONFIG_VERSION = "1.0"; /** Default glob pattern for file matching */ export const DEFAULT_PATTERN = "**/*"; /** Default exclude patterns for collections */ export const DEFAULT_EXCLUDES: readonly string[] = [ ".git", "node_modules", ".venv", ".idea", "dist", "build", "__pycache__", ".DS_Store", "Thumbs.db", ]; /** Valid FTS tokenizer options */ export const FTS_TOKENIZERS = [ "unicode61", "porter", "trigram", "snowball english", ] as const; export type FtsTokenizer = (typeof FTS_TOKENIZERS)[number]; /** Default FTS tokenizer - snowball english for multilingual stemming */ export const DEFAULT_FTS_TOKENIZER: FtsTokenizer = "snowball english"; /** * SQLite `busy_timeout` in milliseconds. Writers wait this long for a lock * before failing with SQLITE_BUSY. Matched to real embedding-pass duration * rather than a fail-fast 5s floor. */ export const DEFAULT_BUSY_TIMEOUT_MS = 60_000; export const MIN_BUSY_TIMEOUT_MS = 1_000; export const MAX_BUSY_TIMEOUT_MS = 600_000; const BUSY_TIMEOUT_RANGE_MESSAGE = "busyTimeoutMs must be an integer between 1000 and 600000"; /** Collection-owned boundary for where indexed content may travel. */ export const EGRESS_POLICIES = ["local_only", "lan", "remote"] as const; export const EgressPolicySchema = z.enum(EGRESS_POLICIES); export type EgressPolicy = z.infer; /** Missing policy is always interpreted as the fail-closed local-only default. */ export const DEFAULT_EGRESS_POLICY: EgressPolicy = "local_only"; /** Source byte materialization boundary for collection indexing. */ export const SOURCE_AVAILABILITY_MODES = ["any", "local"] as const; export const SourceAvailabilitySchema = z.enum(SOURCE_AVAILABILITY_MODES); export type SourceAvailabilityMode = z.infer; export const DEFAULT_SOURCE_AVAILABILITY: SourceAvailabilityMode = "any"; /** Provenance for an effective collection egress policy. */ export const EGRESS_POLICY_SOURCES = [ "explicit", "config_default", "legacy_default", ] as const; export const EgressPolicySourceSchema = z.enum(EGRESS_POLICY_SOURCES); export type EgressPolicySource = z.infer; export interface EffectiveConfiguredEgressPolicy { policy: EgressPolicy; source: Extract; } /** * BCP-47 language tag pattern (simplified, case-insensitive). * Matches: en, de, fr, zh-CN, zh-Hans, und, en-US, etc. */ const BCP47_PATTERN = /^[a-z]{2,3}(-[a-z]{2}|-[a-z]{4})?$/i; /** Validate BCP-47 language hint */ export function isValidLanguageHint(hint: string): boolean { return BCP47_PATTERN.test(hint); } // ───────────────────────────────────────────────────────────────────────────── // Collection Schema // ───────────────────────────────────────────────────────────────────────────── /** * Collection name pattern: lowercase alphanumeric, hyphens, underscores. * 1-64 chars, must start with alphanumeric. */ const COLLECTION_NAME_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/; /** Collection scope key pattern: name (1-64 chars) followed by colon */ const COLLECTION_SCOPE_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}:$/; export const CollectionSchema = z.object({ /** Unique collection identifier (lowercase) */ name: z .string() .regex( COLLECTION_NAME_REGEX, "Collection name must be lowercase alphanumeric with hyphens/underscores, 1-64 chars" ), /** Absolute path to collection root */ path: z.string().min(1, "Path is required"), /** Glob pattern for file matching */ pattern: z.string().default(DEFAULT_PATTERN), /** Extension allowlist (empty = all) */ include: z.array(z.string()).default([]), /** Path patterns to skip */ exclude: z.array(z.string()).default([...DEFAULT_EXCLUDES]), /** Optional shell command to run before indexing */ updateCmd: z.string().optional(), /** Optional BCP-47 language hint */ languageHint: z .string() .refine((val) => isValidLanguageHint(val), { message: "Invalid BCP-47 language code (e.g., en, de, zh-CN, und)", }) .optional(), /** * Explicit collection egress boundary. Absence remains valid for legacy * configs and resolves to local_only rather than relaxing access. */ egressPolicy: EgressPolicySchema.optional(), /** * Durable monotonic token for egress policy/source changes. Legacy configs * start at zero; guarded mutations advance it under the config write lock. */ egressPolicyRevision: z.number().int().nonnegative().optional(), /** * Source content availability policy for indexing. Distinct from egress: * controls whether cloud placeholders may be materialized, not where data * may leave the machine. Omitted / unset means `any` (legacy read behavior); * `local` is opt-in and fails closed where the platform guard is unavailable. */ sourceAvailability: SourceAvailabilitySchema.optional(), /** * Declares the collection as a GNO-managed memory substrate: `remember` * writes fact files here and refuses every collection without the flag. * Omitted means false; ordinary retrieval is unaffected either way. */ memoryManaged: z.boolean().optional(), /** Optional per-collection model overrides */ models: z .object({ embed: z.string().min(1).optional(), rerank: z.string().min(1).optional(), expand: z.string().min(1).optional(), gen: z.string().min(1).optional(), }) .optional(), /** Optional declarative overrides for ambiguous export formats. */ recordAdapters: z .object({ jsonl: z .object({ fieldMapping: JsonlFieldMappingSchema.optional() }) .strict() .optional(), transcript: z .object({ format: z.enum(["json", "srt", "text", "vtt"]) }) .strict() .optional(), }) .strict() .optional(), }); export type Collection = z.infer; export type CollectionModelOverrides = NonNullable; /** Resolve config input without erasing whether the user made an explicit choice. */ export function resolveConfiguredEgressPolicy( collection: Pick ): EffectiveConfiguredEgressPolicy { if (collection.egressPolicy === undefined) { return { policy: DEFAULT_EGRESS_POLICY, source: "config_default", }; } return { policy: collection.egressPolicy, source: "explicit", }; } // ───────────────────────────────────────────────────────────────────────────── // Project Affinity Input // ───────────────────────────────────────────────────────────────────────────── export const TrustedProjectRootSourceSchema = z.enum([ "cli_cwd", "cli_explicit", "cli_worktree", "project_profile", ]); export type TrustedProjectRootSource = z.infer< typeof TrustedProjectRootSourceSchema >; export const LocalProjectAffinityRootSchema = z.object({ source: TrustedProjectRootSourceSchema, path: z.string().min(1), }); export type LocalProjectAffinityRoot = z.infer< typeof LocalProjectAffinityRootSchema >; export const RemoteProjectAffinityRootSchema = z.object({ source: z.literal("remote_hint"), hint: z.string().min(1), }); export type RemoteProjectAffinityRoot = z.infer< typeof RemoteProjectAffinityRootSchema >; export const ProjectAffinityRootSchema = z.discriminatedUnion("source", [ LocalProjectAffinityRootSchema, RemoteProjectAffinityRootSchema, ]); export type ProjectAffinityRoot = z.infer; export const LocalProjectAffinityInputSchema = z.object({ roots: z.array(LocalProjectAffinityRootSchema).max(16).default([]), }); export type LocalProjectAffinityInput = z.infer< typeof LocalProjectAffinityInputSchema >; export const RemoteProjectAffinityInputSchema = z.object({ roots: z.array(RemoteProjectAffinityRootSchema).max(16).default([]), }); export type RemoteProjectAffinityInput = z.infer< typeof RemoteProjectAffinityInputSchema >; export const ProjectAffinityInputSchema = z.object({ roots: z.array(ProjectAffinityRootSchema).max(16).default([]), }); export type ProjectAffinityInput = z.infer; export const PROJECT_AFFINITY_MAX_CONTRIBUTION = 0.03; export const AUXILIARY_RANKING_MAX_CONTRIBUTION = 0.08; export const ProjectAffinityConfigSchema = z.object({ enabled: z.boolean().default(true), contribution: z .number() .finite() .min(0) .max(PROJECT_AFFINITY_MAX_CONTRIBUTION) .default(PROJECT_AFFINITY_MAX_CONTRIBUTION), }); export type ProjectAffinityConfig = z.infer; // ───────────────────────────────────────────────────────────────────────────── // Local Project Profile Bindings // ───────────────────────────────────────────────────────────────────────────── const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/; /** * Machine-local provenance for a project profile projected into this config. * Apply canonicalizes `path`; the schema rejects relative paths and malformed * fingerprints without making the tracked profile machine-specific. */ export const ProjectProfileBindingSchema = z.object({ path: z.string().min(1).refine(isAbsolute, { message: "Project profile binding path must be absolute", }), fingerprint: z.string().regex(SHA256_HEX_REGEX), collection: z.string().regex(COLLECTION_NAME_REGEX), }); export type ProjectProfileBinding = z.infer; // ───────────────────────────────────────────────────────────────────────────── // Context Schema // ───────────────────────────────────────────────────────────────────────────── /** * Context scope types: * - global: "/" - applies to all documents * - collection: "name:" - applies to a specific collection * - prefix: "gno://collection/path" - applies to documents under a path */ export const ScopeTypeSchema = z.enum(["global", "collection", "prefix"]); export type ScopeType = z.infer; /** * Validate scope key format based on type. * - global: must be "/" * - collection: must be "name:" format * - prefix: must be "gno://collection/path" format */ export const ContextSchema = z .object({ /** Type of scope */ scopeType: ScopeTypeSchema, /** Scope key (format depends on scopeType) */ scopeKey: z.string().min(1, "Scope key is required"), /** Context description text */ text: z.string().min(1, "Context text is required"), }) .refine( (ctx) => { switch (ctx.scopeType) { case "global": return ctx.scopeKey === "/"; case "collection": return COLLECTION_SCOPE_REGEX.test(ctx.scopeKey); case "prefix": return ctx.scopeKey.startsWith(URI_PREFIX); default: return false; } }, { message: "Scope key format does not match scope type", } ); export type Context = z.infer; // ───────────────────────────────────────────────────────────────────────────── // Model Preset Schema // ───────────────────────────────────────────────────────────────────────────── export const ModelPresetSchema = z.object({ /** Unique preset identifier */ id: z.string().min(1), /** Human-readable name */ name: z.string().min(1), /** Embedding model URI (hf: or file:) */ embed: z.string().min(1), /** Reranker model URI */ rerank: z.string().min(1), /** Query expansion model URI (defaults to gen for older configs) */ expand: z.string().min(1).optional(), /** Answer generation model URI */ gen: z.string().min(1), }); export type ModelPreset = z.infer; /** Default model presets */ export const DEFAULT_MODEL_PRESETS: ModelPreset[] = [ { id: "slim-tuned", name: "GNO Slim Tuned (Default, ~1GB)", embed: "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf", rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf", expand: "hf:guiltylemon/gno-expansion-slim-retrieval-v1/gno-expansion-auto-entity-lock-default-mix-lr95-f16.gguf", gen: "hf:unsloth/Qwen3-1.7B-GGUF/Qwen3-1.7B-Q4_K_M.gguf", }, { id: "slim", name: "Slim (~1GB)", embed: "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf", rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf", expand: "hf:unsloth/Qwen3-1.7B-GGUF/Qwen3-1.7B-Q4_K_M.gguf", gen: "hf:unsloth/Qwen3-1.7B-GGUF/Qwen3-1.7B-Q4_K_M.gguf", }, { id: "balanced", name: "Balanced (~2GB)", embed: "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf", rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf", expand: "hf:bartowski/Qwen2.5-3B-Instruct-GGUF/Qwen2.5-3B-Instruct-Q4_K_M.gguf", gen: "hf:bartowski/Qwen2.5-3B-Instruct-GGUF/Qwen2.5-3B-Instruct-Q4_K_M.gguf", }, { id: "quality", name: "Quality (Best Answers, ~2.5GB)", embed: "hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf", rerank: "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf", expand: "hf:unsloth/Qwen3-4B-Instruct-2507-GGUF/Qwen3-4B-Instruct-2507-Q4_K_M.gguf", gen: "hf:unsloth/Qwen3-4B-Instruct-2507-GGUF/Qwen3-4B-Instruct-2507-Q4_K_M.gguf", }, ]; export const ModelConfigSchema = z.object({ /** Active preset ID */ activePreset: z.string().default("slim-tuned"), /** Model presets */ presets: z.array(ModelPresetSchema).default(DEFAULT_MODEL_PRESETS), /** Model load timeout in ms */ loadTimeout: z.number().int().min(1).max(2_147_483_647).default(60_000), /** Inference timeout in ms, measured from native evaluation start. */ inferenceTimeout: z.number().int().min(1).max(2_147_483_647).default(30_000), /** Context size used for query expansion generation */ expandContextSize: z.number().int().min(256).default(2_048), /** Keep warm model TTL in ms (5 min) */ warmModelTtl: z.number().default(300_000), }); export type ModelConfig = z.infer; // ──────────────────────────────────────────────────────────────────────────── // Resident HTTP gateway schema // ──────────────────────────────────────────────────────────────────────────── export const HttpGatewayLimitsSchema = z.object({ maxBodyBytes: z .number() .int() .min(1) .max(16 * 1024 * 1024) .optional(), maxRequestsPerMinute: z.number().int().min(1).max(100_000).optional(), maxConcurrentRequests: z.number().int().min(1).max(10_000).optional(), maxQueuedRequests: z.number().int().min(0).max(10_000).optional(), maxSessions: z.number().int().min(1).max(10_000).optional(), sessionIdleTimeoutMs: z.number().int().min(1_000).optional(), }); export const HttpGatewayConfigSchema = z.object({ /** Literal listen address. Defaults to IPv4 loopback. */ host: z.string().min(1).optional(), /** Bearer token file. Required for wildcard/non-loopback binding. */ tokenFile: z.string().min(1).optional(), /** Exact Host header allowlist. */ allowedHosts: z.array(z.string().min(1)).optional(), /** Exact Origin allowlist. Origin-less non-browser clients remain supported. */ allowedOrigins: z.array(z.string().min(1)).optional(), /** Separate mutation authorization; authentication alone never enables it. */ enableWrite: z.boolean().optional(), /** Advertised MCP tool set (`core` | `full`); read at listener start. */ toolProfile: z.enum(MCP_TOOL_PROFILES).optional(), limits: HttpGatewayLimitsSchema.optional(), }); export type HttpGatewayConfig = z.infer; // ──────────────────────────────────────────────────────────────────────────── // Scheduled findings pass (daemon-only, opt-in, report-only) // ──────────────────────────────────────────────────────────────────────────── /** `` with unit s|m|h|d, e.g. `30m`, `6h`, `1d`. */ export const FINDINGS_CADENCE_PATTERN = /^(?[1-9]\d{0,5})(?[smhd])$/; export const DEFAULT_FINDINGS_CADENCE = "6h"; export const MIN_FINDINGS_CADENCE_MS = 10_000; export const MAX_FINDINGS_CADENCE_MS = 30 * 24 * 60 * 60 * 1000; const CADENCE_UNIT_MS: Record = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000, }; /** Parse a findings cadence into milliseconds; null when malformed or out of range. */ export function parseFindingsCadenceMs(raw: string): number | null { const match = FINDINGS_CADENCE_PATTERN.exec(raw.trim()); const value = match?.groups?.value; const unit = match?.groups?.unit; if (!value || !unit) return null; const ms = Number(value) * (CADENCE_UNIT_MS[unit] ?? 0); if (ms < MIN_FINDINGS_CADENCE_MS || ms > MAX_FINDINGS_CADENCE_MS) return null; return ms; } export const FindingsConfigSchema = z.object({ /** Off by default; the daemon never audits on cadence unless asked. */ enabled: z.boolean().default(false), /** Run interval (`10s`..`30d`). Evaluated only when enabled. */ cadence: z .string() .refine((value) => parseFindingsCadenceMs(value) !== null, { message: "cadence must be s|m|h|d between 10s and 30d (e.g. 6h)", }) .default(DEFAULT_FINDINGS_CADENCE), /** Name of an already-configured collection that receives findings records. */ collection: z.string().regex(COLLECTION_NAME_REGEX).optional(), }); export type FindingsConfig = z.infer; // ───────────────────────────────────────────────────────────────────────────── // Content Type Schema // ───────────────────────────────────────────────────────────────────────────── export const CONTENT_TYPE_GRAPH_HINTS = [ "mentions", "works_at", "attended", "decided", "related_to", ] as const; export type ContentTypeGraphHint = (typeof CONTENT_TYPE_GRAPH_HINTS)[number]; export const CONTENT_TYPE_SEARCH_BOOST_MIN = 0.5; export const CONTENT_TYPE_SEARCH_BOOST_NEUTRAL = 1; export const CONTENT_TYPE_SEARCH_BOOST_MAX = 2; export const ContentTypeSchema = z.object({ /** Stable content type identifier */ id: z.string().min(1), /** Relative path prefixes that map to this content type */ prefixes: z.array(z.string().min(1)), /** Note preset ID, resolved post-parse so unknown refs warn-and-drop */ preset: z.string().min(1), /** Reserved for fn-84 typed graph hints; accepted but no-op in fn-83 */ graphHints: z.array(z.string().min(1)).optional(), /** Bounded soft ranking factor; normalized to 1.0 when omitted */ searchBoost: z .number() .finite() .min(CONTENT_TYPE_SEARCH_BOOST_MIN) .max(CONTENT_TYPE_SEARCH_BOOST_MAX) .optional(), /** Marks time-oriented content types; accepted but no-op in fn-83 */ temporal: z.boolean().optional(), }); export type ContentTypeConfig = z.infer; // ───────────────────────────────────────────────────────────────────────────── // Config Schema (root) // ───────────────────────────────────────────────────────────────────────────── export const ConfigSchema = z.object({ /** Config schema version */ version: z.literal(CONFIG_VERSION), /** FTS tokenizer (immutable after init) */ ftsTokenizer: z.enum(FTS_TOKENIZERS).default(DEFAULT_FTS_TOKENIZER), /** * SQLite busy_timeout in milliseconds. Default 60000. Range 1000-600000. * Raise for long embedding passes on slow disks. */ busyTimeoutMs: z .number() .refine( (value) => Number.isInteger(value) && value >= MIN_BUSY_TIMEOUT_MS && value <= MAX_BUSY_TIMEOUT_MS, { message: BUSY_TIMEOUT_RANGE_MESSAGE } ) .default(DEFAULT_BUSY_TIMEOUT_MS), /** Optional terminal hyperlink editor URI template */ editorUriTemplate: z.string().min(1).optional(), /** Collection definitions */ collections: z.array(CollectionSchema).default([]), /** Context metadata */ contexts: z.array(ContextSchema).default([]), /** Opt-in schema-lite content type rules */ contentTypes: z.array(ContentTypeSchema).default([]), /** Optional index-wide chunk size/overlap; omitted preserves legacy defaults. */ chunking: ChunkingConfigSchema.optional(), /** Model configuration */ models: ModelConfigSchema.optional(), /** Resident Streamable HTTP MCP gateway configuration */ gateway: HttpGatewayConfigSchema.optional(), /** Daemon-only scheduled read-only audit writing findings records. Absent means off. */ findings: FindingsConfigSchema.optional(), /** Private local retrieval trace recording. Absent means recording off. */ retrievalTraces: RetrievalTraceConfigSchema.optional(), /** Bounded project-aware retrieval affinity. */ projectAffinity: ProjectAffinityConfigSchema.optional(), /** Machine-local, timestamp-free project profile provenance. */ projectProfileBindings: z .array(ProjectProfileBindingSchema) .max(256) .superRefine((bindings, ctx) => { const paths = new Set(); for (const [index, binding] of bindings.entries()) { if (paths.has(binding.path)) { ctx.addIssue({ code: "custom", message: "Project profile binding paths must be unique", path: [index, "path"], }); } paths.add(binding.path); } }) .optional(), }); export type Config = Omit< z.infer, "contentTypes" | "busyTimeoutMs" | "chunking" > & { contentTypes?: ContentTypeConfig[]; chunking?: Partial; /** Present after schema parse; omitted on hand-built Config objects. */ busyTimeoutMs?: number; }; // ───────────────────────────────────────────────────────────────────────────── // Scope Utilities // ───────────────────────────────────────────────────────────────────────────── /** * Parse a scope string into type and key. * Input formats (from CLI): * - "/" -> { type: 'global', key: '/' } * - "notes:" -> { type: 'collection', key: 'notes:' } * - "gno://notes/projects" -> { type: 'prefix', key: 'gno://notes/projects' } */ export function parseScope( scope: string ): { type: ScopeType; key: string } | null { if (scope === "/") { return { type: "global", key: "/" }; } if (scope.startsWith(URI_PREFIX)) { return { type: "prefix", key: scope }; } if (COLLECTION_SCOPE_REGEX.test(scope)) { return { type: "collection", key: scope }; } return null; } /** * Extract collection name from a scope key. * - "notes:" -> "notes" * - "gno://notes/path" -> "notes" * - "/" -> null */ export function getCollectionFromScope(scopeKey: string): string | null { if (scopeKey === "/") { return null; } if (scopeKey.endsWith(":")) { return scopeKey.slice(0, -1); } if (scopeKey.startsWith(URI_PREFIX)) { const rest = scopeKey.slice(URI_PREFIX.length); const slashIndex = rest.indexOf("/"); return slashIndex === -1 ? rest : rest.slice(0, slashIndex); } return null; }