dsh-code 1.0.5 → 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.
- package/README.en.md +287 -286
- package/README.md +13 -12
- package/bin/deepseek.mjs +118 -3
- package/cordis.patch.yml +17 -7
- package/lib/index.mjs +1148 -347
- package/lib/types/app.d.ts +25 -6
- package/lib/types/attachments.d.ts +36 -4
- package/lib/types/index.d.ts +11 -2
- package/lib/types/provider-settings.d.ts +6 -11
- package/lib/types/render/animations.d.ts +74 -7
- package/lib/types/render/export.d.ts +0 -6
- package/lib/types/render/fuzzy.d.ts +21 -0
- package/lib/types/render/projection.d.ts +45 -4
- package/lib/types/session-directory.d.ts +48 -13
- package/lib/types/store.d.ts +3 -0
- package/package.json +168 -162
- package/src/app.ts +479 -198
- package/src/attachments.ts +110 -11
- package/src/commands.ts +35 -5
- package/src/index.ts +1868 -1779
- package/src/internals.ts +61 -40
- package/src/provider-settings.ts +12 -12
- package/src/render/animations.ts +606 -403
- package/src/render/export.ts +13 -3
- package/src/render/fuzzy.ts +83 -0
- package/src/render/projection.ts +1833 -1621
- package/src/session-directory.ts +94 -16
- package/src/skills.ts +23 -9
- package/src/store.ts +39 -1
- package/src/subagents.ts +26 -3
package/src/attachments.ts
CHANGED
|
@@ -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
|
-
/**
|
|
40
|
-
|
|
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
|
|
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 (
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
81
|
+
next = loadedFor === agent ? [...descriptors] : []
|
|
82
|
+
nextError = cause instanceof Error ? cause.message : String(cause)
|
|
58
83
|
}
|
|
59
|
-
|
|
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())
|