dsh-code 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.en.md +287 -286
  2. package/README.md +16 -13
  3. package/bin/deepseek.mjs +336 -11
  4. package/cordis.patch.yml +26 -18
  5. package/lib/index.mjs +1310 -439
  6. package/lib/types/app.d.ts +25 -6
  7. package/lib/types/attachments.d.ts +36 -4
  8. package/lib/types/git-workflow.d.ts +7 -2
  9. package/lib/types/history.d.ts +18 -11
  10. package/lib/types/index.d.ts +11 -2
  11. package/lib/types/presets.d.ts +4 -1
  12. package/lib/types/provider-settings.d.ts +6 -11
  13. package/lib/types/questions.d.ts +16 -12
  14. package/lib/types/render/animations.d.ts +74 -7
  15. package/lib/types/render/export.d.ts +0 -6
  16. package/lib/types/render/fuzzy.d.ts +21 -0
  17. package/lib/types/render/projection.d.ts +47 -5
  18. package/lib/types/session-directory.d.ts +48 -13
  19. package/lib/types/settings-file.d.ts +8 -0
  20. package/lib/types/store.d.ts +3 -0
  21. package/package.json +168 -159
  22. package/src/app.ts +480 -199
  23. package/src/attachments.ts +110 -11
  24. package/src/commands.ts +35 -5
  25. package/src/git-workflow.ts +29 -10
  26. package/src/history.ts +22 -13
  27. package/src/index.ts +1868 -1752
  28. package/src/internals.ts +61 -40
  29. package/src/permissions.ts +1 -1
  30. package/src/presets.ts +19 -6
  31. package/src/provider-settings.ts +12 -12
  32. package/src/questions.ts +57 -74
  33. package/src/render/animations.ts +606 -403
  34. package/src/render/export.ts +20 -10
  35. package/src/render/fuzzy.ts +83 -0
  36. package/src/render/projection.ts +1833 -1620
  37. package/src/session-directory.ts +94 -16
  38. package/src/settings-file.ts +38 -6
  39. package/src/skills.ts +23 -9
  40. package/src/store.ts +39 -1
  41. package/src/subagents.ts +26 -3
@@ -1,10 +1,10 @@
1
- /** Terminal image-file adapter over the Harness durable attachment service. */
1
+ /** Terminal image- and file-attachment adapter over the Harness durable attachment service. */
2
2
 
3
3
  import { open, readFile, stat } from 'node:fs/promises'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { basename, extname, isAbsolute, resolve } from 'node:path'
6
- import type { AttachmentStore, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
7
- import type { ImageBlock } from '@deepseek-ai/dsh-llm'
6
+ import type { AttachmentStore, ImageMediaType, SaveFileAttachment, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
7
+ import type { FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
8
8
 
9
9
  /** A validated path retained in the editor until submission persists it. */
10
10
  export interface ImagePathInspection {
@@ -14,6 +14,22 @@ export interface ImagePathInspection {
14
14
  readonly bytes: number
15
15
  }
16
16
 
17
+ /** A validated non-image file path retained the same way (0.1.5 file blocks). */
18
+ export interface FilePathInspection {
19
+ readonly path: string
20
+ readonly name: string
21
+ readonly bytes: number
22
+ }
23
+
24
+ /**
25
+ * Terminal-side file admission bounds. Upstream exposes image limits through
26
+ * the attachment service but no file limits (files ride verbatim storage);
27
+ * these keep a dragged file from silently ingesting a disk-sized blob and
28
+ * bound one message the way the image batch is bounded.
29
+ */
30
+ export const MAX_FILE_BYTES = 8 * 1024 * 1024
31
+ export const MAX_FILES_PER_MESSAGE = 8
32
+
17
33
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif'])
18
34
 
19
35
  /** Detect the supported encoded raster formats from bytes, never from a path suffix. */
@@ -36,11 +52,27 @@ export function looksLikeImagePath(path: string): boolean {
36
52
  return IMAGE_EXTENSIONS.has(extname(path).toLowerCase())
37
53
  }
38
54
 
39
- /** Parse a terminal paste/drop containing only one or more image paths. */
40
- export function parsePastedImagePaths(input: string): readonly string[] {
55
+ /**
56
+ * Parse a paste/drop into its image and file paths: image-suffixed tokens
57
+ * stay images, other path-shaped tokens ride as file attachments (0.1.5
58
+ * file blocks), and anything that is neither leaves both empty — the caller
59
+ * then treats the paste as plain text.
60
+ *
61
+ * File tokens are held to an absolute-path-with-shape bar (drive/backslash
62
+ * or a dot-suffixed leaf after a separator): a dropped terminal path always
63
+ * carries one of those, while prose, slash commands, and option flags never
64
+ * do. A POSIX absolute path without any dot-suffixed leaf falls through as
65
+ * text — the @ mention route still attaches such files deliberately.
66
+ */
67
+ export function parsePastedAttachmentPaths(input: string): { readonly images: readonly string[]; readonly files: readonly string[] } {
41
68
  const text = input.trim()
42
- if (text === '') return []
43
- const tokens: string[] = []
69
+ if (text === '') return { images: [], files: [] }
70
+ const images: string[] = []
71
+ const files: string[] = []
72
+ const looksLikeDroppedFile = (path: string): boolean =>
73
+ /^[A-Za-z]:[\\/]/u.test(path)
74
+ || /^\\\\/u.test(path)
75
+ || (/^\/|^\.\.?\//u.test(path) && /\.[A-Za-z0-9]{1,16}$/u.test(path))
44
76
  const matcher = /"([^"]+)"|'([^']+)'|(\S+)/gu
45
77
  for (const match of text.matchAll(matcher)) {
46
78
  const token = match[1] ?? match[2] ?? match[3]
@@ -50,13 +82,20 @@ export function parsePastedImagePaths(input: string): readonly string[] {
50
82
  try {
51
83
  path = fileURLToPath(path)
52
84
  } catch {
53
- return []
85
+ return { images: [], files: [] }
54
86
  }
87
+ if (looksLikeImagePath(path)) images.push(path)
88
+ else files.push(path)
89
+ continue
55
90
  }
56
- if (!looksLikeImagePath(path)) return []
57
- tokens.push(path)
91
+ if (looksLikeImagePath(path)) {
92
+ images.push(path)
93
+ continue
94
+ }
95
+ if (!looksLikeDroppedFile(path)) return { images: [], files: [] }
96
+ files.push(path)
58
97
  }
59
- return tokens
98
+ return { images, files }
60
99
  }
61
100
 
62
101
  /** Validate path, byte size and encoded signature without writing an attachment object. */
@@ -133,3 +172,63 @@ export async function saveImagePaths(
133
172
  checkCancelled()
134
173
  return refs.map(attachment => ({ type: 'image', attachment }))
135
174
  }
175
+
176
+ /** Validate path and byte size for non-image file attachments without writing. */
177
+ export async function inspectFilePaths(
178
+ paths: readonly string[],
179
+ attachments: AttachmentStore | undefined,
180
+ cwd = process.cwd(),
181
+ ): Promise<readonly FilePathInspection[]> {
182
+ if (paths.length === 0) return []
183
+ if (attachments === undefined) throw new Error('file attachments are unavailable in this profile')
184
+ if (paths.length > MAX_FILES_PER_MESSAGE) {
185
+ throw new Error(`too many files (${paths.length}; limit ${MAX_FILES_PER_MESSAGE})`)
186
+ }
187
+ const inspected: FilePathInspection[] = []
188
+ for (const raw of paths) {
189
+ const path = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw)
190
+ let facts: Awaited<ReturnType<typeof stat>>
191
+ try {
192
+ facts = await stat(path)
193
+ } catch (error: unknown) {
194
+ throw new Error(`cannot read file "${raw}": ${error instanceof Error ? error.message : String(error)}`)
195
+ }
196
+ if (!facts.isFile()) throw new Error(`file path is not a file: "${raw}"`)
197
+ if (facts.size > MAX_FILE_BYTES) {
198
+ throw new Error(`file "${basename(path)}" is ${facts.size} bytes; limit ${MAX_FILE_BYTES}`)
199
+ }
200
+ inspected.push({ path, name: basename(path), bytes: facts.size })
201
+ }
202
+ return inspected
203
+ }
204
+
205
+ /** Read and persist an ordered non-image file path list as model file blocks. */
206
+ export async function saveFilePaths(
207
+ paths: readonly string[],
208
+ attachments: AttachmentStore | undefined,
209
+ signal?: AbortSignal,
210
+ ): Promise<readonly FileBlock[]> {
211
+ if (paths.length === 0) return []
212
+ if (attachments === undefined) throw new Error('file attachments are unavailable in this profile')
213
+ // The bounds are re-checked here so a draft inspected earlier still guards
214
+ // the actual read at submission time.
215
+ await inspectFilePaths(paths, attachments)
216
+ const checkCancelled = (): void => {
217
+ if (signal?.aborted === true) throw new Error('file submission cancelled')
218
+ }
219
+ const inputs: SaveFileAttachment[] = []
220
+ for (const path of paths) {
221
+ checkCancelled()
222
+ let data: Uint8Array
223
+ try {
224
+ data = await readFile(path)
225
+ } catch (error: unknown) {
226
+ throw new Error(`cannot read file "${path}": ${error instanceof Error ? error.message : String(error)}`)
227
+ }
228
+ inputs.push({ data, name: basename(path) })
229
+ }
230
+ checkCancelled()
231
+ const refs = await Promise.all(inputs.map(input => attachments.saveFile(input)))
232
+ checkCancelled()
233
+ return refs.map(attachment => ({ type: 'file', attachment }))
234
+ }
package/src/commands.ts CHANGED
@@ -43,20 +43,50 @@ export function watchCommands(ctx: Context): CommandsView {
43
43
  // another session's commands completable here.
44
44
  let loadedFor: Agent | undefined
45
45
  const listeners = new Set<() => void>()
46
+ // Content gate: the host registry allocates a fresh array on every list()
47
+ // call and 0.1.5 emits commands/change for every scoped register AND
48
+ // dispose (a startup registration wave lands inside React's commit
49
+ // windows). A fresh identity per event chains nested passive updates past
50
+ // React's 50-deep limit ("Maximum update depth exceeded"), so an unchanged
51
+ // catalog keeps the previous array identity and notifies nobody — the same
52
+ // discipline the skills gate and the store's frame throttle established.
53
+ const descriptorFingerprint = (list: readonly CommandDescriptor[]): string =>
54
+ JSON.stringify(list.map(descriptor => [descriptor.name, descriptor.description, descriptor.input?.hint ?? '', descriptor.input?.attachments === true]))
55
+ let lastFingerprint = '[]'
56
+ let lastNotifiedError: string | undefined
57
+ const changed = (next: readonly CommandDescriptor[], nextError: string | undefined): boolean =>
58
+ descriptorFingerprint(next) !== lastFingerprint || nextError !== lastNotifiedError
59
+ // Frame throttle: coalesce a same-tick event storm into one notification
60
+ // (the transcript store's NOTIFY_FRAME_MS contract).
61
+ let notifyScheduled = false
62
+ const notify = (): void => {
63
+ if (notifyScheduled) return
64
+ notifyScheduled = true
65
+ setImmediate(() => {
66
+ notifyScheduled = false
67
+ for (const listener of listeners) listener()
68
+ })
69
+ }
46
70
  const refresh = (): void => {
47
71
  if (commands === undefined || agent === undefined) return
72
+ let next: readonly CommandDescriptor[]
73
+ let nextError: string | undefined
48
74
  try {
49
- descriptors = commands.list(agent)
75
+ next = commands.list(agent)
50
76
  loadedFor = agent
51
- error = undefined
52
77
  } catch (cause: unknown) {
53
78
  // Keep the last good catalog for the SAME agent, but change its identity
54
79
  // so subscribers can render the recoverable failure in /help; an agent
55
80
  // that never loaded starts from empty.
56
- descriptors = loadedFor === agent ? [...descriptors] : []
57
- error = cause instanceof Error ? cause.message : String(cause)
81
+ next = loadedFor === agent ? [...descriptors] : []
82
+ nextError = cause instanceof Error ? cause.message : String(cause)
58
83
  }
59
- for (const listener of listeners) listener()
84
+ if (!changed(next, nextError)) return
85
+ descriptors = next
86
+ error = nextError
87
+ lastFingerprint = descriptorFingerprint(next)
88
+ lastNotifiedError = nextError
89
+ notify()
60
90
  }
61
91
  if (commands !== undefined) {
62
92
  ctx.on('commands/change', () => refresh())
@@ -1,4 +1,9 @@
1
- /** Read-only Git inspection used by /diff and /review. */
1
+ /**
2
+ * Read-only Git inspection used by /diff and /review. Every diff
3
+ * invocation carries --no-ext-diff and --no-textconv, so configured
4
+ * external diff drivers and text converters can never execute as a
5
+ * side effect of reading a diff.
6
+ */
2
7
 
3
8
  import { execFile } from 'node:child_process'
4
9
 
@@ -36,12 +41,12 @@ export function parseGitDiffFiles(text: string): readonly GitDiffFile[] {
36
41
  /** Parse the intentionally small, option-safe /diff argument vocabulary. */
37
42
  export function parseGitDiffSpec(argument: string): GitDiffSpec {
38
43
  const value = argument.trim()
39
- if (value === '') return { label: 'working tree vs HEAD', args: ['diff', '--no-ext-diff', '--unified=3', 'HEAD', '--'] }
44
+ if (value === '') return { label: 'working tree vs HEAD', args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', 'HEAD', '--'] }
40
45
  if (value === '--staged' || value === '--cached') {
41
- return { label: 'staged changes', args: ['diff', '--no-ext-diff', '--unified=3', '--cached', '--'] }
46
+ return { label: 'staged changes', args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', '--cached', '--'] }
42
47
  }
43
48
  if (value.startsWith('-') || /\s/u.test(value)) throw new Error('usage: /diff [--staged|git-ref]')
44
- return { label: `changes since ${value}`, args: ['diff', '--no-ext-diff', '--unified=3', value, '--'] }
49
+ return { label: `changes since ${value}`, args: ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', value, '--'] }
45
50
  }
46
51
 
47
52
  function executeGit(cwd: string, args: readonly string[], signal?: AbortSignal): Promise<string> {
@@ -56,8 +61,18 @@ function executeGit(cwd: string, args: readonly string[], signal?: AbortSignal):
56
61
  })
57
62
  }
58
63
 
64
+ /** Arguments for the unstaged-only fallback below. */
65
+ const UNSTAGED_DIFF_ARGS = ['diff', '--no-ext-diff', '--no-textconv', '--unified=3', '--'] as const
66
+
67
+ /** Whether the repository has at least one commit (a HEAD revision). */
68
+ function hasHeadRevision(cwd: string, signal?: AbortSignal): Promise<boolean> {
69
+ return executeGit(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal)
70
+ .then(() => true)
71
+ .catch(() => false)
72
+ }
73
+
59
74
  /**
60
- * Load one complete textual diff without invoking external diff drivers.
75
+ * Load one complete textual diff without invoking external programs.
61
76
  * @param signal - aborted by the caller on session switches/quit, killing the
62
77
  * git subprocess instead of letting a stale repository's diff land later.
63
78
  */
@@ -67,11 +82,15 @@ export async function loadGitDiff(cwd: string, argument: string, signal?: AbortS
67
82
  const text = await executeGit(cwd, spec.args, signal)
68
83
  return { title: `git diff - ${spec.label}`, files: parseGitDiffFiles(text) }
69
84
  } catch (error: unknown) {
70
- // An unborn repository has no HEAD. Preserve useful unstaged output for
71
- // the default form while still surfacing all other Git failures.
72
- if (argument.trim() !== '') throw error
73
- const text = await executeGit(cwd, ['diff', '--no-ext-diff', '--unified=3', '--'], signal)
74
- return { title: 'git diff - working tree', files: parseGitDiffFiles(text) }
85
+ // Only a repository without commits (no HEAD to diff against) may
86
+ // narrow the default form to the unstaged fallback. Every other
87
+ // failure output past the buffer limit, a corrupt index, a missing
88
+ // repository, or the caller aborting between the two calls — must
89
+ // surface, not silently shrink what /diff and /review end up seeing
90
+ // (an aborted probe would otherwise masquerade as an unborn repo).
91
+ if (argument.trim() !== '' || signal?.aborted === true || (await hasHeadRevision(cwd, signal))) throw error
92
+ const text = await executeGit(cwd, UNSTAGED_DIFF_ARGS, signal)
93
+ return { title: 'git diff - working tree (no commits yet)', files: parseGitDiffFiles(text) }
75
94
  }
76
95
  }
77
96
 
package/src/history.ts CHANGED
@@ -42,16 +42,25 @@ export function parseHistoryFile(raw: string, max = HISTORY_MAX_ENTRIES): readon
42
42
  }
43
43
 
44
44
  /**
45
- * Append one entry to the persistent file content: JSON line, capped to the
46
- * newest `max` entries with a trailing newline.
47
- * @param current - existing file content.
48
- * @param text - submission to persist.
49
- * @param max - entry cap.
50
- * @returns the new file content.
45
+ * The append unit for the persistent file: one JSON line, so a multi-line
46
+ * draft still occupies exactly one physical line. Each submission appends
47
+ * this unit at the end of the file, so concurrent terminals add entries
48
+ * after each other. Node chunks one append at 512 KiB: a pasted entry
49
+ * beyond that size could interleave mid-line with another writer's
50
+ * chunks, and the damaged line then drops out at the next parse —
51
+ * recall tolerates the loss by design.
51
52
  */
52
- export function appendHistoryContent(current: string, text: string, max = HISTORY_MAX_ENTRIES): string {
53
- const entries = [...parseHistoryFile(current, max), text].slice(-max)
54
- return entries.map(serializeHistoryEntry).join('\n') + '\n'
53
+ export function historyLine(text: string): string {
54
+ return serializeHistoryEntry(text) + '\n'
55
+ }
56
+
57
+ /**
58
+ * Whether the file on disk differs from its canonical form (deduped and
59
+ * capped). True means stale lines have accumulated and the next boot
60
+ * should rewrite it once, atomically.
61
+ */
62
+ export function needsCompaction(raw: string, max = HISTORY_MAX_ENTRIES): boolean {
63
+ return serializeHistoryList(parseHistoryFile(raw, max)) !== raw
55
64
  }
56
65
 
57
66
  /**
@@ -70,10 +79,10 @@ export function recordLocalEntry(local: readonly string[], text: string, max = H
70
79
  }
71
80
 
72
81
  /**
73
- * Serialize a capped entry list to the history file format (one JSON line per
74
- * entry, trailing newline). The runner writes the in-memory list as the whole
75
- * file, so rapid same-process submissions cannot lose entries to a
76
- * read-modify-write race (the file is never read back before writing).
82
+ * Serialize a capped entry list to the history file format (one JSON line
83
+ * per entry, trailing newline). The boot-time compaction writes this
84
+ * canonical form once when stale lines have accumulated; submissions
85
+ * themselves only ever append a single line.
77
86
  * @param entries - the entries to persist, oldest first.
78
87
  * @returns the file content, '' for an empty list.
79
88
  */