index.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. /**
  2. * Local-filesystem implementation of `ctx.fileReferences`.
  3. *
  4. * @module @deepseek-ai/dsh-file-reference-local
  5. */
  6. import { Context } from '@deepseek-ai/cordis'
  7. import z from '@deepseek-ai/schemastery'
  8. import type { Agent } from '@deepseek-ai/dsh-agent'
  9. import FileReferenceService, {
  10. FILE_REFERENCE_PROMPT,
  11. type FileReferenceCandidate,
  12. } from '@deepseek-ai/dsh-file-reference'
  13. import type {} from '@deepseek-ai/dsh-tools'
  14. import {
  15. DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
  16. DEFAULT_FILE_SEARCH_MAX_ENTRIES,
  17. DEFAULT_FILE_SEARCH_MAX_RESULTS,
  18. WorkspaceFileSearch,
  19. type FileSearchConfig,
  20. } from './search.ts'
  21. export {
  22. DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
  23. DEFAULT_FILE_SEARCH_MAX_ENTRIES,
  24. DEFAULT_FILE_SEARCH_MAX_RESULTS,
  25. WorkspaceFileSearch,
  26. } from './search.ts'
  27. export type { FileSearchConfig } from './search.ts'
  28. export { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference'
  29. export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar'
  30. /** Local file-reference discovery configuration. */
  31. export interface Config {
  32. /** Maximum ranked candidates returned for one query. */
  33. maxResults?: number
  34. /** Maximum indexed files and directories per agent workspace. */
  35. maxEntries?: number
  36. /** Directory basenames never traversed or offered. */
  37. excludedDirectories?: string[]
  38. }
  39. /** Local-filesystem owner of the file-reference discovery service. */
  40. export class LocalFileReferenceService extends FileReferenceService {
  41. static inject = ['agents']
  42. static Config: z<Config> = z.object({
  43. maxResults: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS),
  44. maxEntries: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES),
  45. excludedDirectories: z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]),
  46. })
  47. private readonly config: FileSearchConfig
  48. private readonly searches = new Map<Agent, WorkspaceFileSearch>()
  49. private readonly promptFibers = new Map<Agent, ReturnType<Context['inject']>>()
  50. private readonly promptDisposals = new Set<Promise<void>>()
  51. constructor(ctx: Context, config: Config = {}) {
  52. super(ctx)
  53. this.config = {
  54. maxResults: config.maxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
  55. maxEntries: config.maxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
  56. excludedDirectories: config.excludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
  57. }
  58. validateConfig(this.config)
  59. const installPrompt = (agent: Agent): void => {
  60. if (this.promptFibers.has(agent)) return
  61. const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => {
  62. scope.systemPrompt.section({
  63. name: 'context:file-reference',
  64. order: scope.systemPrompt.getSectionOrder('FILE_REFERENCE'),
  65. text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
  66. })
  67. })
  68. this.promptFibers.set(agent, fiber)
  69. }
  70. const disposePrompt = (agent: Agent): void => {
  71. const fiber = this.promptFibers.get(agent)
  72. if (fiber === undefined) return
  73. this.promptFibers.delete(agent)
  74. const task = fiber.dispose().catch((error: unknown) => {
  75. ctx.logger.warn(`file-reference-local: prompt cleanup failed: ${error instanceof Error ? error.message : String(error)}`)
  76. })
  77. this.promptDisposals.add(task)
  78. void task.finally(() => {
  79. this.promptDisposals.delete(task)
  80. })
  81. }
  82. for (const agent of ctx.agents.list()) installPrompt(agent)
  83. ctx.on('agent/created', ({ agent }) => { installPrompt(agent) })
  84. ctx.on('agent/disposed', ({ agent }) => {
  85. this.searches.get(agent)?.dispose()
  86. this.searches.delete(agent)
  87. disposePrompt(agent)
  88. })
  89. ctx.on('session/event', (session, event) => {
  90. if (event.type !== 'tool/result') return
  91. const agent = ctx.agents.get(session.id)
  92. if (agent !== undefined) this.searches.get(agent)?.invalidate()
  93. })
  94. ctx.effect(() => async () => {
  95. for (const search of this.searches.values()) search.dispose()
  96. this.searches.clear()
  97. const promptFibers = [...this.promptFibers.values()]
  98. this.promptFibers.clear()
  99. await Promise.all([
  100. ...promptFibers.map(fiber => fiber.dispose()),
  101. ...this.promptDisposals,
  102. ])
  103. }, 'file-reference-local: search cache')
  104. }
  105. override list(
  106. agent: Agent,
  107. query: string,
  108. signal: AbortSignal,
  109. ): Promise<FileReferenceCandidate[]> {
  110. let search = this.searches.get(agent)
  111. if (search === undefined) {
  112. search = new WorkspaceFileSearch(agent.session.header.cwd ?? process.cwd(), this.config)
  113. this.searches.set(agent, search)
  114. }
  115. return search.list(query, signal)
  116. }
  117. }
  118. function validateConfig(config: FileSearchConfig): void {
  119. if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) {
  120. throw new Error('file-reference-local: maxResults must be a positive safe integer')
  121. }
  122. if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) {
  123. throw new Error('file-reference-local: maxEntries must be a positive safe integer')
  124. }
  125. if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) {
  126. throw new Error('file-reference-local: excludedDirectories entries must be non-empty directory basenames')
  127. }
  128. }
  129. export default LocalFileReferenceService