files.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. /**
  2. * Instruction-file discovery and bounded, abort-aware provider reads.
  3. *
  4. * @module @deepseek-ai/dsh-agent-instructions/files
  5. */
  6. import { createReadStream } from 'node:fs'
  7. import { stat } from 'node:fs/promises'
  8. import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
  9. import type { FileSystem, FsInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
  10. import { dshHomeDisplay } from '@deepseek-ai/dsh-home-paths'
  11. import { assertNever } from '@deepseek-ai/dsh-util-values'
  12. import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
  13. import { trimmedInstructionDigest } from './digest.ts'
  14. import {
  15. decodeScopeKey,
  16. renderWorkspaceInstructionSet,
  17. type RenderedWorkspaceContext,
  18. USER_GLOBAL_DIRECTORY,
  19. USER_GLOBAL_FILE,
  20. } from './render.ts'
  21. /** An instruction candidate identified by absolute and model-facing paths. */
  22. export interface InstructionFile {
  23. absolutePath: string
  24. displayPath: string
  25. }
  26. /** An instruction file whose UTF-8 content was read successfully. */
  27. export interface LoadedInstructionFile extends InstructionFile {
  28. content: string
  29. /** Provider freshness token when the file was loaded through `ctx.fs`. */
  30. version?: FsVersion
  31. }
  32. interface DiscoveredInstructionFile extends InstructionFile {
  33. target?: FsTarget
  34. size?: number
  35. version?: FsVersion
  36. }
  37. /** Provider metadata for a probed scope candidate before its content is read. */
  38. export interface ProbedInstructionFile extends InstructionFile {
  39. target: FsTarget
  40. version: FsVersion
  41. size?: number
  42. }
  43. interface DiscoverOptions {
  44. cwd: string
  45. dshHome?: string
  46. projectRootMarkers?: string[]
  47. instructionFileCandidates?: string[]
  48. localInstructionFileCandidates?: string[]
  49. projectRoot?: string
  50. signal?: AbortSignal
  51. }
  52. interface LoadOptions extends DiscoverOptions {
  53. maxBytes: number
  54. maxSourceBytes?: number
  55. replacePreviousBaseline?: boolean
  56. }
  57. /** Rendered baseline plus the successfully read and byte-budget-retained files. */
  58. export interface RenderedInstructionSet {
  59. rendered: RenderedWorkspaceContext
  60. /** Successfully read candidates before content deduplication and byte budgeting. */
  61. observed: LoadedInstructionFile[]
  62. /** Candidates retained by content deduplication and byte budgeting. */
  63. included: LoadedInstructionFile[]
  64. }
  65. /** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
  66. export type ScopeInstructionProbe =
  67. | { kind: 'present'; file: ProbedInstructionFile }
  68. | { kind: 'absent' }
  69. | { kind: 'unavailable' }
  70. interface StatFileInfo {
  71. target?: FsTarget
  72. size?: number
  73. version?: FsVersion
  74. }
  75. type StatFileProbe =
  76. | { kind: 'present'; info: StatFileInfo }
  77. | { kind: 'absent' }
  78. | { kind: 'unavailable' }
  79. function signalOptions(signal?: AbortSignal): { signal: AbortSignal } | undefined {
  80. return signal === undefined ? undefined : { signal }
  81. }
  82. function isMissingPathError(error: unknown): boolean {
  83. return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
  84. }
  85. function isMissingProviderPathError(error: unknown): boolean {
  86. return error instanceof Error && 'code' in error && error.code === 'FS_NOT_FOUND'
  87. }
  88. async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
  89. try {
  90. signal?.throwIfAborted()
  91. // stat (not lstat) follows a final-component symlink so a link to a regular
  92. // file loads; a broken link surfaces as ENOENT and is treated as absent below.
  93. const info = await stat(path)
  94. signal?.throwIfAborted()
  95. if (!info.isFile()) return { kind: 'absent' }
  96. return { kind: 'present', info: { size: info.size } }
  97. } catch (error: unknown) {
  98. signal?.throwIfAborted()
  99. return isMissingPathError(error) ? { kind: 'absent' } : { kind: 'unavailable' }
  100. }
  101. }
  102. async function fsStatFile(
  103. path: string,
  104. fileSystem: FileSystem,
  105. signal?: AbortSignal,
  106. ): Promise<StatFileProbe> {
  107. // resolve() follows a final-component symlink to its target's stable identity;
  108. // stat then classifies that target. A link to a regular file loads, while a
  109. // missing path or non-file target (including a link to a directory) is absent.
  110. try {
  111. const target = await fileSystem.resolve(path, signalOptions(signal))
  112. signal?.throwIfAborted()
  113. const info = await fileSystem.stat(target, signal)
  114. signal?.throwIfAborted()
  115. if (info?.type !== 'file') return { kind: 'absent' }
  116. return {
  117. kind: 'present',
  118. info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
  119. }
  120. } catch {
  121. signal?.throwIfAborted()
  122. return { kind: 'unavailable' }
  123. }
  124. }
  125. async function statFile(
  126. path: string,
  127. fileSystem?: FileSystem,
  128. signal?: AbortSignal,
  129. ): Promise<StatFileProbe> {
  130. return fileSystem === undefined ? nodeStatFile(path, signal) : fsStatFile(path, fileSystem, signal)
  131. }
  132. async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: AbortSignal): Promise<boolean> {
  133. if (fileSystem !== undefined) {
  134. try {
  135. const target = await fileSystem.resolve(path, signalOptions(signal))
  136. return await fileSystem.stat(target, signal) !== undefined
  137. } catch (error: unknown) {
  138. signal?.throwIfAborted()
  139. if (isMissingProviderPathError(error)) return false
  140. throw error
  141. }
  142. }
  143. try {
  144. signal?.throwIfAborted()
  145. await stat(path)
  146. signal?.throwIfAborted()
  147. return true
  148. } catch (error: unknown) {
  149. signal?.throwIfAborted()
  150. if (isMissingPathError(error)) return false
  151. throw error
  152. }
  153. }
  154. /**
  155. * Walk upward to the first directory containing a configured root marker.
  156. * @param cwd - absolute session working directory where the walk begins.
  157. * @param markers - child names that identify a project root.
  158. * @param fileSystem - optional provider used instead of host filesystem probes.
  159. * @param signal - cancellation for provider and host probes.
  160. * @returns the discovered project root, or `cwd` when no marker exists.
  161. * @throws the original marker metadata error or cancellation reason when a probe is unavailable.
  162. */
  163. export async function findProjectRoot(
  164. cwd: string,
  165. markers: readonly string[],
  166. fileSystem?: FileSystem,
  167. signal?: AbortSignal,
  168. ): Promise<string> {
  169. let current = resolve(cwd)
  170. for (;;) {
  171. for (const marker of markers) {
  172. if (await existsAsMarker(join(current, marker), fileSystem, signal)) return current
  173. }
  174. const parent = dirname(current)
  175. if (parent === current) return resolve(cwd)
  176. current = parent
  177. }
  178. }
  179. /**
  180. * Build the inclusive root-to-cwd directory chain.
  181. * @param root - root directory expected to contain or equal `cwd`.
  182. * @param cwd - most-specific directory in the chain.
  183. * @returns directories ordered from broadest to most specific.
  184. */
  185. export function ancestorChain(root: string, cwd: string): string[] {
  186. const chain: string[] = []
  187. let current = resolve(cwd)
  188. const resolvedRoot = resolve(root)
  189. while (current !== resolvedRoot) {
  190. chain.push(current)
  191. const parent = dirname(current)
  192. /* v8 ignore next -- discovery always supplies cwd or an ancestor root. */
  193. if (parent === current) break
  194. current = parent
  195. }
  196. chain.push(resolvedRoot)
  197. return chain.reverse()
  198. }
  199. /**
  200. * Find descendant directories crossed between a cwd and a touched file.
  201. * @param root - session cwd that bounds nested discovery.
  202. * @param touchedPath - absolute path or path relative to `root`.
  203. * @returns descendant directories from shallowest through the touched file's parent.
  204. */
  205. export function descendantDirsBetween(root: string, touchedPath: string): string[] {
  206. const resolvedRoot = resolve(root)
  207. const targetPath = isAbsolute(touchedPath) ? resolve(touchedPath) : resolve(resolvedRoot, touchedPath)
  208. const targetDir = dirname(targetPath)
  209. const rel = relative(resolvedRoot, targetDir)
  210. if (rel.length === 0 || rel.startsWith('..') || isAbsolute(rel)) return []
  211. return ancestorChain(resolvedRoot, targetDir).slice(1)
  212. }
  213. /**
  214. * Convert an absolute instruction path to its project-root-relative display form.
  215. * @param root - project root used as the display base.
  216. * @param path - absolute path to display.
  217. * @returns the root-relative path.
  218. */
  219. export function relativeDisplay(root: string, path: string): string {
  220. return relative(root, path)
  221. }
  222. async function allExistingInstructionFiles(
  223. dir: string,
  224. root: string,
  225. instructionFileCandidates: readonly string[],
  226. fileSystem?: FileSystem,
  227. signal?: AbortSignal,
  228. ): Promise<DiscoveredInstructionFile[]> {
  229. const found: DiscoveredInstructionFile[] = []
  230. for (const candidate of instructionFileCandidates) {
  231. const path = join(dir, candidate)
  232. const probe = await statFile(path, fileSystem, signal)
  233. switch (probe.kind) {
  234. case 'present':
  235. found.push({ absolutePath: path, displayPath: relativeDisplay(root, path), ...probe.info })
  236. continue
  237. // A missing candidate is skipped; a transient provider failure skips only
  238. // that candidate so the remaining independent candidates still load.
  239. case 'absent':
  240. case 'unavailable':
  241. continue
  242. /* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
  243. default:
  244. assertNever(probe, 'StatFileProbe')
  245. }
  246. }
  247. return found
  248. }
  249. async function discoverInstructionFiles(
  250. options: DiscoverOptions,
  251. fileSystem?: FileSystem,
  252. ): Promise<DiscoveredInstructionFile[]> {
  253. const config = resolveDiscoveryConfig(options)
  254. const files: DiscoveredInstructionFile[] = []
  255. const seen = new Set<string>()
  256. const addFile = (file: DiscoveredInstructionFile): void => {
  257. if (seen.has(file.absolutePath)) return
  258. seen.add(file.absolutePath)
  259. files.push(file)
  260. }
  261. const userGlobal = join(config.dshHome, USER_GLOBAL_FILE)
  262. const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
  263. switch (userGlobalProbe.kind) {
  264. case 'present':
  265. addFile({
  266. absolutePath: userGlobal,
  267. displayPath: userGlobalDisplayPath(config.dshHome),
  268. ...userGlobalProbe.info,
  269. })
  270. break
  271. case 'absent':
  272. case 'unavailable':
  273. break
  274. /* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
  275. default:
  276. assertNever(userGlobalProbe, 'StatFileProbe')
  277. }
  278. const cwd = resolve(options.cwd)
  279. const projectRoot = options.projectRoot
  280. ?? await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
  281. for (const dir of ancestorChain(projectRoot, cwd)) {
  282. for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
  283. for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
  284. addFile(file)
  285. }
  286. }
  287. }
  288. return files
  289. }
  290. /**
  291. * Discover host-visible user-global and root-to-cwd instruction candidates.
  292. * All present candidates in each directory are returned; trimmed-content
  293. * duplicates are collapsed later, once content is read.
  294. * @param options - cwd, home, root marker, and candidate configuration.
  295. * @returns path-deduplicated instruction candidates in model precedence order.
  296. * @throws the original root-marker metadata error or cancellation reason when
  297. * discovery cannot identify the project root.
  298. */
  299. export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
  300. return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
  301. }
  302. async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterable<string> {
  303. const stream = createReadStream(path, { encoding: 'utf8', signal })
  304. for await (const chunk of stream) yield String(chunk)
  305. }
  306. async function readBounded(
  307. file: { absolutePath: string; target?: FsTarget; size?: number },
  308. maxSourceBytes: number,
  309. fileSystem?: FileSystem,
  310. signal?: AbortSignal,
  311. ): Promise<string | undefined> {
  312. // TODO(total-instruction-read-bound): enforce an aggregate source budget
  313. // across a complete baseline or reconciliation batch; the render budget is
  314. // applied only after every accepted file has been read under this per-file cap.
  315. signal?.throwIfAborted()
  316. if (file.size !== undefined && file.size > maxSourceBytes) return undefined
  317. try {
  318. const chunks = fileSystem === undefined || file.target === undefined
  319. ? nodeTextChunks(file.absolutePath, signal)
  320. : await fileSystem.streamText(file.target, signal)
  321. const parts: string[] = []
  322. let bytes = 0
  323. for await (const chunk of chunks) {
  324. signal?.throwIfAborted()
  325. bytes += Buffer.byteLength(chunk, 'utf8')
  326. if (bytes > maxSourceBytes) return undefined
  327. parts.push(chunk)
  328. }
  329. signal?.throwIfAborted()
  330. return parts.join('')
  331. } catch {
  332. signal?.throwIfAborted()
  333. // A file may disappear or become unreadable after its metadata probe.
  334. return undefined
  335. }
  336. }
  337. /**
  338. * Drop later candidates whose trimmed content duplicates an earlier sibling in
  339. * the same directory. Different directories never collapse even when identical;
  340. * within one directory the earliest candidate in discovery order is kept and its
  341. * original bytes are rendered. A candidate that symlinks a sibling resolves to
  342. * the same content and collapses here like any byte-identical real file.
  343. * @param files - loaded files in discovery order.
  344. * @returns the retained files in the same order.
  345. */
  346. export function dedupInstructionFilesByDirectory(files: LoadedInstructionFile[]): LoadedInstructionFile[] {
  347. const keptDigestsByDir = new Map<string, Set<string>>()
  348. const kept: LoadedInstructionFile[] = []
  349. for (const file of files) {
  350. const dir = dirname(file.displayPath)
  351. let digests = keptDigestsByDir.get(dir)
  352. if (digests === undefined) {
  353. digests = new Set()
  354. keptDigestsByDir.set(dir, digests)
  355. }
  356. const digest = trimmedInstructionDigest(file.content)
  357. if (digests.has(digest)) continue
  358. digests.add(digest)
  359. kept.push(file)
  360. }
  361. return kept
  362. }
  363. /**
  364. * Discover, read, and render the baseline instruction chain.
  365. * @param options - discovery, source-size, byte-budget, and cancellation configuration.
  366. * @param fileSystem - optional provider used instead of host filesystem reads.
  367. * @returns rendered baseline context, or undefined when nothing can be loaded.
  368. * @throws the original root-marker metadata error or cancellation reason when
  369. * discovery cannot identify the project root.
  370. */
  371. export async function loadBaselineInstructions(
  372. options: LoadOptions,
  373. fileSystem?: FileSystem,
  374. ): Promise<RenderedWorkspaceContext | undefined> {
  375. return (await loadBaselineInstructionSet(options, fileSystem))?.rendered
  376. }
  377. /**
  378. * Load a baseline together with the files retained after rendering.
  379. * @param options - discovery, source-size, byte-budget, and cancellation configuration.
  380. * @param fileSystem - optional provider used instead of host filesystem reads.
  381. * @returns rendered context and retained files, an explicit empty replacement set, or undefined when empty or disabled.
  382. */
  383. export async function loadBaselineInstructionSet(
  384. options: LoadOptions,
  385. fileSystem?: FileSystem,
  386. ): Promise<RenderedInstructionSet | undefined> {
  387. const config = resolveConfig(options)
  388. if (config.maxBytes <= 0 || !Number.isFinite(config.maxBytes)) return undefined
  389. if (config.maxSourceBytes <= 0 || !Number.isFinite(config.maxSourceBytes)) return undefined
  390. const discovered = await discoverInstructionFiles(options, fileSystem)
  391. const loaded: LoadedInstructionFile[] = []
  392. for (const file of discovered) {
  393. const content = await readBounded(file, config.maxSourceBytes, fileSystem, options.signal)
  394. if (content !== undefined) {
  395. loaded.push({
  396. absolutePath: file.absolutePath,
  397. displayPath: file.displayPath,
  398. content,
  399. ...file.version === undefined ? {} : { version: file.version },
  400. })
  401. }
  402. }
  403. const deduped = dedupInstructionFilesByDirectory(loaded)
  404. if (deduped.length === 0) {
  405. if (options.replacePreviousBaseline !== true) return undefined
  406. const { rendered, included } = renderWorkspaceInstructionSet([], {
  407. maxBytes: config.maxBytes,
  408. replacePreviousBaseline: true,
  409. })
  410. return {
  411. rendered,
  412. observed: [],
  413. included,
  414. }
  415. }
  416. const { rendered, included } = renderWorkspaceInstructionSet(deduped, {
  417. maxBytes: config.maxBytes,
  418. ...options.replacePreviousBaseline === undefined
  419. ? {}
  420. : { replacePreviousBaseline: options.replacePreviousBaseline },
  421. })
  422. return {
  423. rendered,
  424. observed: loaded,
  425. included,
  426. }
  427. }
  428. /**
  429. * Probe the current provider metadata for one per-candidate instruction scope.
  430. * @param scope - a {@link candidateScopeKey} identifying a directory and candidate file.
  431. * @param projectRoot - project root used to resolve and display project scopes.
  432. * @param resolved - normalized plugin configuration.
  433. * @param fileSystem - provider used to resolve and stat scope candidates.
  434. * @param signal - cancellation for provider probes.
  435. * @returns present metadata, confirmed absence, or temporary unavailability.
  436. */
  437. export async function probeScopeInstruction(
  438. scope: string,
  439. projectRoot: string,
  440. resolved: ResolvedConfig,
  441. fileSystem: FileSystem,
  442. signal?: AbortSignal,
  443. ): Promise<ScopeInstructionProbe> {
  444. const { directory, candidateName } = decodeScopeKey(scope)
  445. const dir = directory === USER_GLOBAL_DIRECTORY
  446. ? resolved.dshHome
  447. : directory === '.' ? projectRoot : join(projectRoot, directory)
  448. const absolutePath = join(dir, candidateName)
  449. // resolve() follows a final-component symlink; stat then classifies the target.
  450. // A non-file target (missing, or a link to a directory) is a confirmed absence;
  451. // only a provider exception is reported as unavailable.
  452. let target: FsTarget
  453. let info: FsInfo | undefined
  454. try {
  455. target = await fileSystem.resolve(absolutePath, signalOptions(signal))
  456. info = await fileSystem.stat(target, signal)
  457. } catch {
  458. signal?.throwIfAborted()
  459. return { kind: 'unavailable' }
  460. }
  461. if (info?.type !== 'file') return { kind: 'absent' }
  462. const file: ProbedInstructionFile = {
  463. absolutePath,
  464. displayPath: directory === USER_GLOBAL_DIRECTORY ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
  465. target,
  466. version: info.version,
  467. ...info.size === undefined ? {} : { size: info.size },
  468. }
  469. return { kind: 'present', file }
  470. }
  471. /**
  472. * Read one already-probed scope candidate under the configured source cap.
  473. * @param file - winning provider candidate and its metadata snapshot.
  474. * @param maxSourceBytes - maximum UTF-8 bytes accepted from the source.
  475. * @param fileSystem - provider used for the streaming read.
  476. * @param signal - cancellation for provider streaming.
  477. * @returns loaded content with the probed version, or undefined when unavailable.
  478. */
  479. export async function readScopeInstruction(
  480. file: ProbedInstructionFile,
  481. maxSourceBytes: number,
  482. fileSystem: FileSystem,
  483. signal?: AbortSignal,
  484. ): Promise<LoadedInstructionFile | undefined> {
  485. const content = await readBounded(file, maxSourceBytes, fileSystem, signal)
  486. if (content === undefined) return undefined
  487. return {
  488. absolutePath: file.absolutePath,
  489. displayPath: file.displayPath,
  490. content,
  491. version: file.version,
  492. }
  493. }
  494. function userGlobalDisplayPath(dshHome: string): string {
  495. return `${dshHomeDisplay(dshHome)}/AGENTS.md`
  496. }