Skip to content

dsh-ptc-lite

Verified

dsh-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 with truncated: false and spillPath: 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:

  1. the host's per-call read caps (readLimit, readMaxBytes, readMaxLineLength), which cut the VALUE itself;
  2. dsh-spill-policy's maxInlineTokens, which bounds the model-facing result and exempts exactly one tool by name (exec.name === "read") β€” so a native read is exempt while the same bytes inside a run_code result 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:

  1. typeof (await tools.grep({pattern:'fn ', path:'<a wide tree>'})) prints string β€” the canonical value really is a string;
  2. that same wide grep prints 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);
  3. seq 1 8000 returns a window plus a pointer whose file holds all 8,000 lines β€” below the host spill threshold, so the log is this plugin's;
  4. seq 1 60000 does the same with the host's own spill file (348,894 B, 60,000 lines);
  5. tools.bash({ run_in_background: true }) still returns a job id, and job_output appends each read to out/<session>/jobs/<job_id>.log;
  6. tools.read({ file_path: …, limit: 5 }) prints Nβ‡₯text plus the host footer β€” the argument is file_path, not path; a plain read of a 158 KB file returns all 3,712 lines (175,529 characters) with no cap footer, instead of the 1,192-line window the shipped readMaxBytes: 51200 produced.

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. Bumping version does not change the path. Note the asymmetry: a new row mounts live, but an existing row whose name changed 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 in jobs/. 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 shared jobs/ 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 read costs ~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: several offset/limit windows 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 in cordis.patch.yml and in the plugin's DEFAULTS) 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 for shellMaxChars: 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 bash row does so while jobs mounts) 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/presentResult are 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. So JSON.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.