feat(fs): add a model-facing directory listing tool

`ctx.fs.listDir` has shipped since the filesystem seam gained it, with skill
discovery as its only consumer; the model-facing tool was deferred to a
separate decision. Nothing else could answer "what is in this directory":
`rg --files` backs glob and grep and never emits a directory entry, so an
empty directory is invisible, no output says which names are directories,
and no output gives an entry count.

`list` takes an optional `path`, defaulting to the session workspace so the
common question needs no argument, and returns the direct children of one
directory with their type. Two presentation rules carry it: directories sort
first, then files, then non-regular children, each alphabetically — so
truncation loses leaves rather than the tree — and the footer always states
the complete listing's size and composition, so a capped view can never read
as a whole directory.

It emits no `fs/observed`: seeing a filename is not reading a file, and a
listing must never satisfy the read-before-write gate.
This commit is contained in:
NI0317
2026-07-28 11:59:52 +08:00
parent 5888503d5f
commit d4614f92d6
48 changed files with 761 additions and 46 deletions

View File

@@ -1,16 +1,19 @@
/**
* Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation,
* read windows, formatting, and observation events, never a concrete provider. An optional
* event policy supplies mutation guards; without one the tools use unconditional provider calls.
* Model-facing list, read, write, and edit tools over `ctx.fs`. This package owns schemas,
* validation, read windows, listing order, formatting, and observation events, never a concrete
* provider. An optional event policy supplies mutation guards; without one the tools use
* unconditional provider calls.
* @module @deepseek-ai/dsh-tool-fs
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-user-approval'
import { applyListTool } from './list.ts'
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
import { applyWriteTool } from './write.ts'
import { applyEditTool } from './edit.ts'
import { LIST_MAX_ENTRIES } from './list-render.ts'
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
import { FsSandboxSurface } from './sandbox.ts'
@@ -22,6 +25,8 @@ export const inject = ['tools', 'fs', 'systemPrompt']
/** Plugin config (all optional — `Config` supplies the defaults). */
export interface Config {
/** Maximum entries one `list` call renders inline; the footer still reports the complete count. */
listMaxEntries?: number
/** Default and maximum number of lines returned by one `read` call. */
readLimit?: number
/** Maximum characters returned for a single line before truncation. */
@@ -33,6 +38,7 @@ export interface Config {
}
export const Config: z<Config> = z.object({
listMaxEntries: z.number().default(LIST_MAX_ENTRIES),
readLimit: z.number().default(READ_LIMIT),
readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH),
readMaxBytes: z.number().default(READ_MAX_BYTES),
@@ -42,21 +48,23 @@ export const Config: z<Config> = z.object({
/** The shape after schemastery applied the defaults. */
type ResolvedConfig = Required<Config>
/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */
/** Every read or listing cap counts lines/chars/bytes/entries — a positive integer, or windowing arithmetic misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`tool-fs: ${name} must be a positive integer`)
}
}
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
/** Register the full `list`/`read`/`write`/`edit` filesystem tool suite. */
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveInteger('listMaxEntries', resolved.listMaxEntries)
assertPositiveInteger('readLimit', resolved.readLimit)
assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength)
assertPositiveInteger('readMaxBytes', resolved.readMaxBytes)
assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize)
applyListTool(ctx, { maxEntries: resolved.listMaxEntries })
applyReadTool(ctx, {
limit: resolved.readLimit,
maxLineLength: resolved.readMaxLineLength,