config.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. /**
  2. * Configuration normalization for workspace instruction discovery and rendering.
  3. *
  4. * @module @deepseek-ai/dsh-agent-instructions/config
  5. */
  6. import { relative } from 'node:path'
  7. import z from '@deepseek-ai/schemastery'
  8. import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
  9. const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
  10. const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
  11. const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.local.md', 'CLAUDE.local.md'] as const
  12. const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
  13. const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
  14. /** User-facing workspace instruction loader configuration. */
  15. export interface Config {
  16. /** Harness home containing the fixed user-global `AGENTS.md`; defaults to `$DSH_HOME` or `~/.dsh`. */
  17. dshHome?: string
  18. /** Directory entries that identify the project root while walking upward from the session cwd. */
  19. projectRootMarkers?: string[]
  20. /** UTF-8 byte cap for one rendered baseline or dynamic batch; non-positive or non-finite disables loading. */
  21. maxBytes: number
  22. /** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
  23. maxSourceBytes?: number
  24. /**
  25. * Ordered same-directory project candidates; every existing file loads, with
  26. * per-directory trimmed-content duplicates collapsed to the earliest candidate.
  27. */
  28. instructionFileCandidates?: string[]
  29. /**
  30. * Ordered same-directory local-overlay candidates loaded after the base files
  31. * under the same per-directory trimmed-content dedup; empty disables the overlay.
  32. */
  33. localInstructionFileCandidates?: string[]
  34. }
  35. export const Config: z<Config> = z.object({
  36. dshHome: z.string(),
  37. projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
  38. maxBytes: z.number().required(),
  39. maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
  40. instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
  41. localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES]),
  42. })
  43. /** Normalized instruction discovery configuration. */
  44. export interface ResolvedDiscoveryConfig {
  45. dshHome: string
  46. projectRootMarkers: string[]
  47. instructionFileCandidates: string[]
  48. localInstructionFileCandidates: string[]
  49. }
  50. /** Normalized configuration used by discovery and reconciliation. */
  51. export interface ResolvedConfig extends ResolvedDiscoveryConfig {
  52. maxBytes: number
  53. maxSourceBytes: number
  54. }
  55. /**
  56. * Identify the discovery, precedence, and budget semantics of one baseline.
  57. * @param config - normalized plugin configuration.
  58. * @param cwd - absolute session working directory.
  59. * @param projectRoot - project root selected for the current baseline.
  60. * @returns stable serialized identity for compatibility checks on resume.
  61. */
  62. export function workspaceBaselineIdentity(
  63. config: ResolvedConfig,
  64. cwd: string,
  65. projectRoot: string,
  66. ): string {
  67. return JSON.stringify({
  68. projectRoot: relative(cwd, projectRoot),
  69. projectRootMarkers: config.projectRootMarkers,
  70. maxBytes: config.maxBytes,
  71. maxSourceBytes: config.maxSourceBytes,
  72. instructionFileCandidates: config.instructionFileCandidates,
  73. localInstructionFileCandidates: config.localInstructionFileCandidates,
  74. })
  75. }
  76. /**
  77. * Resolve defaults, the harness home, and valid same-directory candidates.
  78. * @param config - user-facing plugin configuration.
  79. * @returns normalized runtime configuration.
  80. */
  81. export function resolveConfig(config: Config): ResolvedConfig {
  82. return {
  83. ...resolveDiscoveryConfig(config),
  84. maxBytes: config.maxBytes,
  85. maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES,
  86. }
  87. }
  88. /**
  89. * Resolve the subset of configuration used before instruction content is rendered.
  90. * @param config - optional discovery controls.
  91. * @returns normalized home, root markers, and instruction candidates.
  92. */
  93. export function resolveDiscoveryConfig(
  94. config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates' | 'localInstructionFileCandidates'>,
  95. ): ResolvedDiscoveryConfig {
  96. return {
  97. dshHome: resolveDshHome(config.dshHome),
  98. projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
  99. instructionFileCandidates: resolveInstructionFileCandidates(
  100. config.instructionFileCandidates,
  101. DEFAULT_INSTRUCTION_FILE_CANDIDATES,
  102. ),
  103. localInstructionFileCandidates: resolveInstructionFileCandidates(
  104. config.localInstructionFileCandidates,
  105. DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES,
  106. ),
  107. }
  108. }
  109. function resolveInstructionFileCandidates(candidates: string[] | undefined, fallback: readonly string[]): string[] {
  110. return (candidates ?? [...fallback]).filter(candidate => (
  111. !RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
  112. ))
  113. }