dsh-ptc-lite
Verifieddsh-ptc-lite Β· v0.1.0 Β· ISC
PTC-Lite agent preset, its prompt trim, and its terse tool results
Install
dsh plugin add dsh-ptc-lite Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Creators
Readme
dsh-ptc-lite
The PTC-Lite mode as one installable bundle: the agent preset, the system-prompt trim it rides with, and the plugin that makes tool results terse.
Everything here used to be inline in
C:\Users\korac\.dsh\profiles\web\cordis.patch.yml (a ~200-line preset
declaration) plus a profile-local plugins/trim-system-prompt.mjs. The profile
now keeps only the deployment preference for which preset is selected β
@deepseek-ai/dsh-agent-preset-registry with selectedDefault: ptc-lite β and
this package owns the rest.
What it adds
| Row | Kind | What it does |
|---|---|---|
preset-ptc-lite |
@deepseek-ai/dsh-agent-preset |
Declares the ptc-lite preset: PTC mode with goal/todo/plan mode/present/workflow removed |
prompt-trim |
dsh-ptc-lite/trim-system-prompt |
Drops a deny list of prompt sections and trims the generated SDK text |
compact-search |
dsh-ptc-lite/compact-search |
Shadows grep/glob/read/bash/job_output per agent so they return compact strings |
The result policy
PTC mode hands the model's program each tool's canonical value. So the bytes
the model actually reads are whatever its own console.log prints β and the
host's grepMaxMatches: 250 / globMaxResults: 100 are rendering caps that
never fire for a run_code sub-dispatch. One measured wide search
(grep "fn " over a vendored Rust tree) returned 1,613 matches = 226,235 B
β 56k tokens in a single call.
The fix is structural rather than advisory: compact-search re-registers the
five tools in each agent's own scope with output.schema = { type: 'string' },
which renderToolsSdk projects into the SDK as a string binding β so the
model's existing habit (console.log(await tools.grep(...))) already prints
compact text, and no new tool name has to be remembered.
| Tool | Printed | Lifeboat |
|---|---|---|
grep |
per-file groups, 40 matches (max 5 per file, path order), then a count header with "search too broad, narrow it" | none needed: re-running narrower is the fix |
glob |
60 paths + the same instruction | none needed |
read |
every line, verbatim, Nβ₯text, plus the host's own paging footer |
the window's own offset/limit |
bash |
stdout verbatim, or a 6,000-char window (40% head / 60% tail) when there is more of it; markers only when something notable happened | the host spillPath, else a log this plugin writes first |
job_output |
a 4,000-char window (same 40/60 split) | every read is appended to <session>/jobs/<job_id>.log first |
Why the sample is ordered by path rather than by discovery: ripgrep walks with
several threads, so the host's flat match array is in completion order.
Three identical grep "fn " calls over one tree returned the same 1,613 matches
in the same 73 files, yet started with context_guard.rs/binary.rs, then
background_status.rs/binary.rs, then binary.rs/background_status.rs
(measured with the packaged rg binary). A head window taken in that order
prints a different sample on every call while claiming to show "the first 40", so
the plugin sorts by (path, lineNumber) first: one call is repeatable, the
header is true, and max 5 per file keeps the window from collapsing onto one
dense file (measured: 4 of 73 files uncapped, 10 with the cap).
Two rules the code is built around:
- Re-derivable output may be capped (searches), because a too-broad search is a wrong search β and the cap says so loudly rather than quietly narrowing.
- Non-re-derivable output may not (shell stdout, job reads). Measured host
behaviour: output below
maxOutputBytes(64,000 B) comes back complete withtruncated: falseandspillPath: nullβ it exists nowhere else β so the rule is never print less than you persist.
read is never capped by this plugin: it is content the model asked for, and
a partial answer only forces a second read (only the JSON envelope is dropped).
Two other layers do bound it, and in PTC both apply:
- the host's per-call read caps (
readLimit,readMaxBytes,readMaxLineLength), which cut the VALUE itself; dsh-spill-policy'smaxInlineTokens, which bounds the model-facing result and exempts exactly one tool by name (exec.name === "read") β so a nativereadis exempt while the same bytes inside arun_coderesult are not.
Measurements on a 158,133 B / 3,712-line file: with the shipped caps a plain
read returned lines 1-1192 (51,338 printed characters, the rest cut by
readMaxBytes: 51200), and maxInlineTokens: 12500 (~50,000 characters) trimmed
that window by only about 1 KB β so the read cap, not the policy, was the wall.
This preset raises the read caps for its own agents (8,000 lines / 200 KB) and the
deployment's patch file matches the budget (spill-policy: maxInlineTokens: 55000); the same read now arrives whole (175,529 characters, (End of file - total 3712 lines)). Widening layer 2 is deployment-wide on purpose: a second
policy mounted inside the preset would only add a tighter bound.
Layout
cordis.patch.yml the bundle's patch layer (preset + 2 plugin rows)
trim-system-prompt/index.js prompt trim plugin
compact-search/index.js terse-result plugin (renderers + shadow registration)
*/locale/{en,zh}.json, */icon.svg Plugin Manager display metadata
test/ node --test suites, fixtures taken from host schemas
Install and update
Published to npm as dsh-ptc-lite
(ISC):
# in the DSH plugin manager, or equivalently:
dsh plugin --profile web add dsh-ptc-lite
For development against this checkout, link it instead β edits are live after a host restart (or a file rename β see the gotcha below):
dsh plugin --profile web add D:\Koracan\source\typescript\ptc-lite
The profile's package.json then carries
"dsh-ptc-lite": "link:D:/Koracan/source/typescript/ptc-lite" and
dsh-ptc-lite in dsh.profile.bundles. Removing it (remove_bundle) leaves
selectedDefault: ptc-lite pointing at a preset that no longer exists, so
restore the inline declaration or select another preset first.
Verification
cd D:\Koracan\source\typescript\ptc-lite
node --test
# the one premise the design rests on, against the installation's real renderer:
DSH_TOOLS_DIR="C:/Users/korac/AppData/Local/mise/installs/npm-deepseek-ai-dsh/0.2.1-alpha.2/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-tools" node --test test/sdk-render.test.mjs
The first command covers the renderers against canonical host shapes,
registration behavior, the prompt trim, and the manifest/exports/display
resources. The second proves with the real renderToolsSdk that
output.schema = { type: 'string' } renders as a string binding, and that the
host's structured output would have rendered as a nested object instead.
End-to-end acceptance needs an agent created after the change (the shadow is registered per agent). The acceptance run of 2026-10-14 gives the shape of it; a fresh run repeats those six checks in a new PTC-Lite session:
typeof (await tools.grep({pattern:'fn ', path:'<a wide tree>'}))printsstringβ the canonical value really is a string;- that same wide
grepprints a count header + "narrow it", byte-identical on repeated calls (measured: 1,613 matches in 73 files, 2,940 chars / 51 lines for 40 printed matches); seq 1 8000returns a window plus a pointer whose file holds all 8,000 lines β below the host spill threshold, so the log is this plugin's;seq 1 60000does the same with the host's own spill file (348,894 B, 60,000 lines);tools.bash({ run_in_background: true })still returns a job id, andjob_outputappends each read toout/<session>/jobs/<job_id>.log;tools.read({ file_path: β¦, limit: 5 })printsNβ₯textplus the host footer β the argument isfile_path, notpath; a plainreadof a 158 KB file returns all 3,712 lines (175,529 characters) with no cap footer, instead of the 1,192-line window the shippedreadMaxBytes: 51200produced.
Gotchas
- Module cache. The loader imports a plugin by URL and Node caches it for
the life of the process. For a
link:-installed bundle the URL is a file path, so an edit needs a host restart or a renamed file/row specifier to take effect. Bumpingversiondoes not change the path. Note the asymmetry: a new row mounts live, but an existing row whosenamechanged is a config-only update (Fiber.update(config)restarts the same plugin and never re-imports), so a specifier rename alone does not swap a module. out/only grows. Every log this plugin writes lives under~/.dsh/ptc-helpers/out/<session-id>/β shell windows and promoted output at its root, job reads injobs/. Rolling cleanup is planned and not implemented; the per-session split at least makes a session's junk one directory. A call with no calling agent uses<out>/_anonymous/.- Logs are keyed by session, and that is load-bearing. A job id is a short
per-session counter (
bash-332), so two sessions in one host can hold the same id: a sharedjobs/directory let the second session append into the first session's file while the printed pointer still called it that session's complete read. - A raised read cap is paid every turn. A single 200 KB
readcosts ~50,000 tokens (the estimator charges 4 characters per token) and stays in history until compaction, so one huge read is not free merely because it now fits in one call: severaloffset/limitwindows remain the cheaper way to work through a large file. - Budgets are row config, applied per agent.
shellMaxChars,jobMaxChars,grepMaxMatches,grepPerFileMatches,globMaxPaths,headShare(all optional, defaults incordis.patch.ymland in the plugin'sDEFAULTS) are validated at mount and captured when an agent is created, so editing them reaches agents created after the edit through a live reload β no restart, unlike a code edit. A bad value fails the mount loudly rather than silently printing the wrong amount. The window split is tail-weighted (headShare: 0.4) because shell and job output are process streams whose verdict is at the end. Measured basis forshellMaxChars: 6000, over 4,251 bash calls in 80 sessions: p50 556 chars, p90 4,067, and 5.5% exceed the window β a band the host itself printed inline with no pointer at all (it only spills above 64,000 B). - The shadow wraps the definition visible at agent creation. It captures the
host tool once, because re-resolving at call time would resolve the shadow
itself. A host row that re-registers its tool afterwards (the
bashrow does so whilejobsmounts) is picked up as long as the remount happens before the agent is created, which is the normal order. - Transcript detail changes. The host's
presentationMeta/presentResultare typed against the host's canonical value, and this plugin hands them a string, so they are dropped: PTC sub-dispatch rows render their text with the generic card instead of a structured search/read card. The durable log entry still holds the full text this plugin returned. - A successful command's stdout is returned verbatim. No
[exit code: 0]line is added: markers ([exit code: N]for a non-zero N,[killed by signal: β¦],[timed out after β¦],[stopped: β¦],[sandbox denied: β¦]) appear only when something notable happened, exactly as the host renderer does and exactly as the deployment persona promises. SoJSON.parse(await tools.bash({ command: 'β¦ --json' }))works, and an empty successful result reads(no output). The cost is that success is an inference (absent marker) rather than a printed fact.