index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. /**
  2. * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
  3. * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
  4. * optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
  5. * against a leaf `cordis.yml` until the whole tree has settled.
  6. * @module @deepseek-ai/dsh-app-boot
  7. */
  8. import { pathToFileURL } from 'node:url'
  9. import { readFileSync } from 'node:fs'
  10. import { basename, dirname, join, resolve } from 'node:path'
  11. import * as yaml from 'js-yaml'
  12. import { Context } from 'cordis'
  13. import Loader from '@cordisjs/plugin-loader'
  14. import Include, { type PatchOptions } from '@cordisjs/plugin-include'
  15. import { resolveDshHome } from '@deepseek-ai/dsh-paths'
  16. // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
  17. import type {} from '@deepseek-ai/dsh-system-prompt'
  18. /**
  19. * Resolve the config to boot. Replay swaps a `cordis.yml` basename for
  20. * `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
  21. * @param configPath - the requested config path (absolute, or relative to `cwd`).
  22. * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
  23. * basename.
  24. * @param cwd - the base a relative `configPath` resolves against.
  25. * @returns the absolute path of the config to boot.
  26. */
  27. export function resolveConfigPath(
  28. configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
  29. ): string {
  30. const absolute = resolve(cwd, configPath)
  31. if (snapshotMode !== 'replay') return absolute
  32. const dir = dirname(absolute)
  33. const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
  34. return resolve(dir, replayName)
  35. }
  36. /**
  37. * Load the optional gitignored `.env` from `dir`. Missing files fall back to the
  38. * ambient environment; other read failures are reported through `warn`.
  39. * @param binName - the diagnostic prefix on the warn line.
  40. * @param dir - the directory whose `.env` to load.
  41. * @param warn - sink for the one-line misconfiguration diagnostic.
  42. */
  43. export function loadEnv(
  44. binName: string, dir: string = process.cwd(),
  45. warn: (line: string) => void = line => void process.stderr.write(line),
  46. ): void {
  47. try {
  48. process.loadEnvFile(resolve(dir, '.env'))
  49. } catch (error) {
  50. if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
  51. warn(`${binName}: failed to load .env: ${String(error)}\n`)
  52. }
  53. // ENOENT (no .env) is fine — rely on the ambient environment.
  54. }
  55. }
  56. /** File inside the Harness home holding the personal loader overlay patches. */
  57. export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
  58. // The include's YAML dialect: `!!js` scalars become expression nodes the
  59. // Loader interpolates against each entry's context at mount time. Personal
  60. // patches are parsed with the same schema so they may reference `process.env`.
  61. // Load-only: this schema never dumps, so no `predicate`/`represent`.
  62. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  63. kind: 'scalar',
  64. resolve: data => typeof data === 'string',
  65. construct: data => ({ __jsExpr: String(data) }),
  66. })
  67. const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType)
  68. /**
  69. * Load the optional personal overlay patches (`config.yaml` under the Harness
  70. * home). The file is a top-level YAML array of loader patch entries
  71. * (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides
  72. * and `insert` lists, with `!!js` expressions allowed. A missing file means
  73. * "no personal overlay"; an unreadable, unparsable, or non-array file throws —
  74. * a present personal config that cannot apply is a misconfiguration and must
  75. * fail loud at boot, never be silently skipped.
  76. * @param binName - the diagnostic prefix on the thrown error.
  77. * @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`).
  78. * @returns the parsed patches, or `undefined` when the file does not exist.
  79. */
  80. export function loadPersonalPatches(
  81. binName: string, dir: string = resolveDshHome(),
  82. ): PatchOptions[] | undefined {
  83. const file = join(dir, PERSONAL_CONFIG_FILENAME)
  84. let content: string
  85. try {
  86. content = readFileSync(file, 'utf8')
  87. } catch (error) {
  88. if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
  89. throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
  90. }
  91. let parsed: unknown
  92. try {
  93. parsed = yaml.load(content, { schema: personalPatchesSchema })
  94. } catch (error) {
  95. throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`)
  96. }
  97. if (!Array.isArray(parsed)) {
  98. throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`)
  99. }
  100. // A present personal config that cannot apply is a misconfiguration and must
  101. // fail loud here — the include only warns per entry at mount.
  102. parsed.forEach((entry, index) => {
  103. if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
  104. throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
  105. }
  106. })
  107. return parsed as PatchOptions[]
  108. }
  109. /**
  110. * The slice of `process` {@link installFailLoud} needs — injectable so tests
  111. * exercise the handler without registering on (or exiting) the real process.
  112. */
  113. export interface FailLoudProcess {
  114. on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
  115. off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
  116. stderr: { write(chunk: string): unknown }
  117. exit(code: number): void
  118. }
  119. /**
  120. * Install before boot to turn a late unhandled plugin-init rejection into one
  121. * labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
  122. * the returned function removes the handler.
  123. * @param binName - the diagnostic prefix on the fatal-failure line.
  124. * @param proc - the process slice to register on; tests inject a fake.
  125. * @returns the uninstaller that removes the rejection handler.
  126. */
  127. export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
  128. const handler = (err: unknown): void => {
  129. proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
  130. proc.exit(1)
  131. }
  132. proc.on('unhandledRejection', handler)
  133. return () => void proc.off('unhandledRejection', handler)
  134. }
  135. /**
  136. * After the tree settles, reject entries with no fiber, which indicates a
  137. * swallowed module-import failure. Disabled entries are the only valid
  138. * fiber-less state.
  139. * @param ctx - the settled context whose loader entries to audit.
  140. * @param binName - the diagnostic prefix on the thrown error.
  141. */
  142. export function assertEntriesLoaded(ctx: Context, binName: string): void {
  143. const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
  144. if (failed.length > 0) {
  145. const names = failed.map(entry => entry.options.name).join(', ')
  146. throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
  147. }
  148. }
  149. /**
  150. * Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
  151. * session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
  152. * makes `id` readable as the bare identifier `resumeSessionId` in a config
  153. * `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
  154. * so resuming a session needs no environment variable. A bin that never
  155. * provides it leaves the identifier undeclared, so configs read it defensively
  156. * (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
  157. */
  158. export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
  159. /**
  160. * Boot the Loader against `absoluteConfigPath` and return only after the whole
  161. * tree settles. Entry names load through the Loader's internal module loader
  162. * against `baseUrl` (the config directory), which may live outside
  163. * `node_modules` reach and, unbuilt, cannot load vendored source; the
  164. * bootstrap include is therefore statically imported and mounted as the
  165. * `cordis:include` builtin, loading through the ambient module pipeline
  166. * (vite/tsx/plain ESM) while the included tree's own specifiers stay
  167. * config-relative. A missing fiber rejects here; a later init rejection is
  168. * handled by {@link installFailLoud}. Built bins need the Loader's native
  169. * helper for bare plugin specifiers; relative specifiers do not.
  170. * @param binName - the diagnostic prefix for load-failure errors.
  171. * @param absoluteConfigPath - the config to include; must already be absolute
  172. * (see {@link resolveConfigPath}).
  173. * @param patches - optional overlay patches applied over the included tree
  174. * (see {@link loadPersonalPatches}); an empty list mounts none.
  175. * @param prepare - optional host setup run against the root context before any Loader entry mounts.
  176. * @returns the root context once every entry has started.
  177. */
  178. export async function boot(
  179. binName: string,
  180. absoluteConfigPath: string,
  181. patches?: PatchOptions[],
  182. prepare?: (ctx: Context) => Promise<void> | void,
  183. ): Promise<Context> {
  184. const ctx = new Context()
  185. await prepare?.(ctx)
  186. ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
  187. await ctx.plugin(Loader)
  188. ctx.loader.builtins.include = Include
  189. await ctx.loader.create({
  190. name: 'cordis:include',
  191. config: {
  192. path: pathToFileURL(absoluteConfigPath).href,
  193. ...patches !== undefined && patches.length > 0 ? { patches } : {},
  194. },
  195. })
  196. await ctx.loader.await()
  197. assertEntriesLoaded(ctx, binName)
  198. return ctx
  199. }
  200. /** Prompt-section name for the harness-source location line an app bin adds after boot. */
  201. export const HARNESS_SOURCE_SECTION = 'harness:source'
  202. /**
  203. * Add a global prompt section naming the on-disk path to the harness source
  204. * checkout the running bin was launched from, so the agent knows where its own
  205. * source lives (the self-referential `dsh-tool-cordis` toolset reads and edits
  206. * it). Call once on the settled boot context ({@link boot}); the section orders
  207. * just after the harness identity opener (`-100`) and before the deployment
  208. * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
  209. * augment, so this is then a no-op that returns `undefined`. The section is
  210. * registered against the `systemPrompt` service's fiber, so a dev HMR reload of
  211. * that plugin drops it until the next boot.
  212. * @param ctx - the settled boot context whose global system prompt to augment.
  213. * @param sourceRoot - the absolute path to the harness checkout root.
  214. * @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted.
  215. */
  216. export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined {
  217. const systemPrompt = ctx.get('systemPrompt')
  218. if (systemPrompt === undefined) return undefined
  219. return systemPrompt.section({
  220. name: HARNESS_SOURCE_SECTION,
  221. order: -99,
  222. text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`,
  223. })
  224. }