Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add dsh-plugin-redact
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
README
In-place redaction and row-level rollback for DSH session logs (session.vN.jsonl.zstd), usable from inside dsh-tui.
No new session, no deleting the whole conversation, no moving the log around: it rewrites the exact bytes in the original file and leaves the log readable by the DSH reader.
| Package | dsh-plugin-redact |
| Version | 0.1.0 |
| License | MIT |
| Runtime | Node.js >= 22.15.0 (uses zlib.zstdCompressSync / zstdDecompressSync) |
| In-TUI command | /redact list|nodes|pick|scan|hide|plan|apply|verify|purge|rollback|undo |
| Offline CLI | dsh-redact inspect|paths|verify|plan|apply|cut|graph |
| Hard dependency | Consumes the host commands service only (inject: ['commands']); publishes no service. /redact pick additionally needs the TUI row's tuiDialogs and an explicit allowDialogs: true (off by default — see §2.5) |
0. What problem it solves
Once a tool result, command output, or fetched page enters a session, a persistence batch writes it into session.vN.jsonl.zstd, where it then sits on disk indefinitely — replayed in later history, fed back into later context, indexed or exported. The content need not be a "secret": it may be material you are not allowed to retain, a credential, or simply wrong or outdated information that should stop influencing later decisions. You decide the criterion; this tool only guarantees that the bytes already on disk are removed or rewritten — without corrupting the log.
It does two things: rewrite (replace matched text/fields in place, or blank a whole line) and cut (drop trailing rows, or middle rows with an explicit renumber pass). Afterwards the session still opens, replays, and appends normally.
Keep four different outcomes apart, because four subcommands own them:
/redact hide— immediately removes matching tool results from the current session's model view by appendingsurfaceOp: {op:'replace'}replacement nodes. The model stops seeing them from the next request, with no restart. Disk bytes are unchanged. It locates its targets three ways: afindinplan.json, an index from/redact nodes, or a log line number (see §3.1). To see the complete list and pick from it, use/redact pick: it renders the nodes multi-line in the TUI's own panel and lets you choose with the arrow keys, free of the 200-cell single-line limit. Each entry shows index / log line / turn / tool name / size, with the tool call's command line on the line below it (governed byargsCells, which can truncate it or switch it off entirely) — together these are what let you tell which node you are looking at (see §3.2). The tool result's body is never shown. ⚠️pickis off by default (allowDialogs: false): a modal dialog makes the TUI yield the chat keyboard, and a pending approval panel then deadlocks the UI (see §2.5). Enabling it takes explicit configuration; without it, use/redact nodesfor the numbering plus/redact hide <index> --commit— nothing else is affected./redact apply— actually rewrites the log bytes on disk (in place, same row count). It requires the session not to be in use (otherwise an explicit--allow-live), so it is best run through the offline CLI while the session is closed./redact rollback <turns>— retracts conversation that already happened: truncates the last N complete turns at aturn/endboundary. This is a suffix drop, the safest kind of deletion./redact undo— reverts your own redaction: restores from the newest quarantine backup (validated before it is installed: frames decodable, header legal,seqdense, references legal, no torn tail, and no fewer events than the current log; a backup that fails is refused with a pointer to an older.quarantine-*/.before-undo-*copy).
They are complementary: hide to stop the bleeding (content stops entering later context) → apply once the session is closed to remove the bytes → undo if you regret it. With hide alone the original stays on disk in full.
1. ⚠️ Read this first: deleting a middle row makes the whole log unreadable
[!WARNING] The DSH reader has one non-negotiable invariant: every row's
seqmust equal its row index (0-based; row 1 is the header and is exempt). The decoder assertsevent.seq === eventCountrow by row.Therefore deleting any middle row makes the reader reject the entire log as corrupt — you do not lose one row, you lose the session.
In-place rewriting, by contrast, is safe: row count, row order,
type,seq,time, and all cross-references stay identical, so it is completely transparent to the reader.
So the primary mechanism is three kinds of in-place rewrite; deletion is a supplement:
| Operation | What it does | Structural effect | When to use it |
|---|---|---|---|
substitutions |
Replace a matching substring inside every string value (can be narrowed with path / lines) |
None: row count / seq / type unchanged |
The same text is scattered across many rows (tool results, meta, echoed arguments) and you want it gone everywhere at once |
setFields |
Replace the value at one JSON path on one row | None | You have located the exact spot with paths and want only that one field changed |
blankLines |
Replace every string value on those rows with the placeholder (default [已移除]) |
None: type / seq / time / id untouched |
The whole row's content is void, but the row itself must stay (event counts, turn structure, tool-call pairing) |
dropLines |
Delete those rows | ⚠️ Yes: row indices shift | Safe only for a trailing suffix (references always point backwards). A middle drop requires renumber: true |
renumber |
Permit middle deletion: renumber every seq and remap all backward references |
⚠️ Large | You genuinely need the row to not exist and accept rewritten seqs. A dangling reference (some row references a deleted row) is still refused |
Prefer the first three when you only need the content gone; reach for dropLines only when the row itself must disappear.
2. Install
Two ways. A is the normal one (the package declares dsh.bundle, so it becomes a profile layer); B is for a hand-written row.
2.1 A: as a bundle (recommended)
# from a local directory (relative specs are anchored to the invoking directory first)
dsh plugin --profile <name> add ./dsh-plugin-redact
# or from npm / git
dsh plugin --profile <name> add dsh-plugin-redact
dsh plugin --profile <name> add github:<you>/dsh-plugin-redact#<commit>
dsh plugin --profile <name> <args...> ensures the profile exists, then forwards the arguments to pnpm with the profile directory as the working directory — so add / remove / why / update all work, and pnpm must be on PATH. Because the package declares
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
a successful install adds it to that profile's dsh.profile.bundles and applies its cordis.patch.yml as a layer (a single - insert: row with id: dsh-redact).
2.2 B: as a plain profile row
Edit <DSH_HOME>/profiles/<name>/cordis.patch.yml (this is the user layer; the neighbouring cordis.yml is a generated empty root — do not edit it):
# top-level YAML array; an insert with no id appends top-level rows
- insert:
- id: dsh-redact
name: dsh-plugin-redact # bare name: resolved from <profile>/node_modules
config:
root: !!js dshHomePath('sessions')
cacheRoot: !!js dshHomePath('storages')
placeholder: '[已移除]'
Two resolution rules that matter:
The package must be resolvable from the profile's
node_modules. A bare name is looked up by Node starting at the profile directory (<profile>/node_modules,<DSH_HOME>/profiles/node_modules, …), so either rundsh plugin --profile <name> add ./dsh-plugin-redactfirst, or install the package into the profile's dependencies. Leaving the package in some unrelated directory and referencing it by name will not resolve.On Windows an absolute local path must be a
file://URL. The loader uses dynamicimport(); a rawC:\...\index.jsis parsed as the URL schemec:and fails withERR_UNSUPPORTED_ESM_URL_SCHEME. Generate it withpathToFileURL, and point at the entry file (a directory fails withERR_UNSUPPORTED_DIR_IMPORT):node -p "require('node:url').pathToFileURL('C:/Users/me/dsh-plugin-redact/index.js').href" # => file:///C:/Users/me/dsh-plugin-redact/index.jsA relative path (
./dsh-plugin-redact/index.js) also works; it resolves relative to the including file (the profile directory).
2.3 Config
| Field | Default | Meaning |
|---|---|---|
root |
<DSH_HOME>/sessions |
Session-log root; layout is <root>/<project-key>/<session-id>/session.vN.jsonl.zstd |
cacheRoot |
<DSH_HOME>/storages |
Derived-cache root; purge deletes only here |
placeholder |
[已移除] |
Text written by blankLines (substitutions' replace defaults to the empty string, i.e. plain deletion) |
argsCells |
120 |
Display-cell budget (a CJK character costs 2) for the tool-call command line shown in listings. The command line is what makes an entry recognisable, but it can itself contain sensitive search terms, hence the switch: 0 shows no command line at all (nothing in the listing file or the panel — not even a …). It affects only the nodes full file and pick's option description; the nodes notification never shows a command line and is unaffected |
dialogTimeoutMs |
15000 |
The timeoutMs passed to both of pick's dialogs. It is the bound on "how long the UI may stay unusable when the panel is not rendered" (see §2.5); 0 disables dialogs entirely — pick returns an error and never calls the dialog service. The default used to be 120000 (2 minutes), which was a bad bet: the user hit exactly that freeze in practice |
allowDialogs |
false |
The master switch for pick's modal dialogs, off by default (rationale in §2.5): parking the promise in TuiDialogStore makes chat yield the keyboard unconditionally (the prompt is disabled), the dialog's only mount point can be silently overridden by an approval panel, and approval has no timeout — so "press pick while an approval is pending" necessarily locks the keyboard, Ctrl+C cannot even exit, and only the timeout frees it. Set true to enable pick; while it is off, pick returns an explanation pointing at nodes + hide <index>. A non-boolean value throws |
<DSH_HOME> falls back to ~/.dsh. The shipped cordis.patch.yml uses the deployment's own !!js dshHomePath(...) helper, exactly like the official session-persistence-jsonl row, so it follows DSH_HOME.
Config validation (done synchronously in apply(), so it fails early and visibly):
- An empty
config:(YAML hands overnull) or no config at all (undefined) is treated as{}, with each of the six fields taking its default. This is deliberate: otherwise one emptyconfig:would abort the whole profile's boot. - Still rejected: non-objects (strings, arrays) and any non-string or empty-string value for
root/cacheRoot/placeholder. The errors readdsh-redact: invalid config: expected an objectanddsh-redact: invalid config: $.root must be a non-empty string. - The two numeric fields must be non-negative integers:
argsCells/dialogTimeoutMsgiven as-1,1.5,'5000'ornullall throw at boot and register no command —dsh-redact: invalid config: $.argsCells must be a non-negative integer,dsh-redact: invalid config: $.dialogTimeoutMs must be a non-negative integer.0is valid and meaningful ("show no command line" and "disable dialogs" respectively). allowDialogsmust be a boolean:'true',1ornullall throwdsh-redact: invalid config: $.allowDialogs must be a boolean(and register no command); omitting it means the defaultfalse.- Unknown keys only warn, never reject (matching the platform's own config semantics, so a slipped key name cannot break boot):
dsh-redact: ignoring unknown config key(s): palceholder, extra, emitted throughctx.logger.warn. - This package does not export a Schemastery
Config: when installed aslink:, the package's own real path cannot resolvenode_modulesupwards and@deepseek-ai/schemasteryis unresolvable (observed asERR_MODULE_NOT_FOUND). The equivalent validation above is therefore hand-written insideapply().
2.4 Confirm it mounted
dsh --profile <name> --dump-config # prints the composed config without booting
The output should contain the id: dsh-redact row, annotated with the layer it came from. (This command also rewrites the profile's generated empty cordis.yml and may heal profiles/node_modules links; both are idempotent.)
In a patchReload: live profile, editing cordis.patch.yml takes effect without a restart, and the new command enters the TUI's / menu live via commands/change. Editing the contents of an already-imported module is not hot — restart or rename the file. Creating the plugin file for the first time needs no restart.
2.5 /redact pick is optional and off by default: enabling it takes two things (install requirement)
[!IMPORTANT]
pickis off by default (allowDialogs: false) — not out of caution, but because of a root-cause finding. Parking the dialog's promise in the TUI'sTuiDialogStoremakes chat yield the keyboard unconditionally (Chat.js:2566: the prompt is disabled while a questionnaire / approval / plugin dialog is pending); the dialog panel's only mount point (Chat.js:3585) is silently overridden by an approval panel, and approval has no timeout — so pressing/redact pickwhile an approval is pending necessarily locks the keyboard,Ctrl+Ccannot even exit (exitOnCtrlC: false), and only the timeout frees it. The only structurally safe move is not to park the promise at all, so the default path is/redact nodesfor the numbering →/redact hide <index> --commit(no functionality is lost).To actually use the panel, two things must both hold:
- Config
allowDialogs: true— the master switch (defaultfalse). While it is off,pickreturns this and never touches the service:pick 的模态对话框默认关闭(它会让 TUI 键盘卡住,且 approval 挂起时必然复现)· 用 /redact nodes 看编号,再 /redact hide <序号> --commit · 确要启用请设 allowDialogs: true 并自行承担风险- A row-level
inject: [commands, tuiDialogs]— this removes the startup "admission race" (next paragraph). The packagedcordis.patch.tui.ymlalready writes this step and the one above.
Why the row-level inject is still needed. The tuiDialogs runtime has an admission guard outside its try/catch: the caller must be a registered, live, non-root activation (@deepseek-harness-tui/dsh-tui, lib/types/dsh-adapter/dialogs.js:103-111: when bindOwnerEffect(...) does not bind, it immediately runs pending.onAbort() and the promise settles with the cancelled value; the registration itself is established by compositionRoot(ctx) in the TuiDialogRuntime constructor, same file :185). If this row becomes ACTIVE before the first dsh-tui adapter module that installs that composition-root tracker, every select / confirm only writes one logger warning and returns — no panel, and the user sees nothing but "cancelled". A row-level inject makes Cordis wait for that service before activating this row, which removes the race (measured: in the worst order the panel went from 0/6 to 6/6).
The packaged layer is cordis.patch.tui.yml (shipped with the package; exports also resolves dsh-plugin-redact/cordis.patch.tui.yml); it writes both the row-level inject and allowDialogs: true into that row. The row you want when enabling pick looks like this:
# the row that enables /redact pick (row-level inject + an explicit opt-in)
- insert:
- id: dsh-redact
name: dsh-plugin-redact
inject: [commands, tuiDialogs]
config:
root: !!js dshHomePath('sessions')
placeholder: '[已移除]'
cacheRoot: !!js dshHomePath('storages')
allowDialogs: true
(The packaged cordis.patch.tui.yml also carries a long comment block explaining the admission guard, the cost, and why you must not try an !!js disabled self-guard to choose between the variants automatically — Entry.disabled is a live getter, so the verdict flips with mount progress and the boot aborts.)
Three ways to use it, pick one:
- Paste it into your own profile patch layer (recommended, most direct): copy those
insertlines into<DSH_HOME>/profiles/<name>/cordis.patch.yml(both the packaged file and the snippet above already carryallowDialogs: truein the config — do not drop it when copying). - Point
--patchat the packaged file:
That file's config carries all four fields (includingdsh --profile <name> --patch "C:/path/to/node_modules/dsh-plugin-redact/cordis.patch.tui.yml" # Windows absolute paths need a file:// URL: node -p "require('node:url').pathToFileURL('C:/.../cordis.patch.tui.yml').href"allowDialogs: true), so this route is complete on its own; layers from several--patchflags stack in order. - Copy the snippet: if you only want the panel and not the file itself, copy the YAML above.
Three measured semantics that save rework:
- A row-level
injectis APPENDED, not a replacement: the module's staticcommandsdeclaration still applies (cordis merges viaInject.resolve(entry.options.inject, fiber.inject)), socommandsinside[commands, tuiDialogs]is a harmless duplicate — you do not have to choose between them. configis a whole-row replacement (a matching patch replaces the entire config; there is no deep merge), so write every field you want to take effect into that row — which is exactly whyallowDialogs: truehas to be in it, not inherited from somewhere else.- After applying it,
dsh --profile <name> --dump-configshows that row'sinjectand config (see §2.4).
[!WARNING] Cost: apply this layer only when you run a dsh-tui frontend and genuinely want that panel. If your profile has no
tuiDialogsprovider (non-TUI frontend, or the dsh-tui row disabled / failed to load), this row stays PENDING (state 0) forever, and the boot promotes a PENDING row to a fatal error — the whole profile fails to boot, not a single row failing quietly.
Why the packaged default layer cordis.patch.yml deliberately omits that inject: on a non-TUI profile (e.g. web) tuiDialogs never exists, so declaring it would leave the row PENDING and abort the whole profile boot. The default layer therefore stays graceful.
Off-by-default and degraded behaviour are both explicit — never silent:
allowDialogs !== true→ the "off by default" explanation above (kind: error), without touching the dialog service;allowDialogson butdialogTimeoutMs: 0→对话框已被配置禁用(dialogTimeoutMs: 0)· 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件;allowDialogson but the frontend has no such service →当前前端没有对话框服务 tuiDialogs · 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件;allowDialogson and the service present, but the host did not admit this row (the admission race) → the plugin uses elapsed time to tell (nobody presses Esc within 50 ms):
The confirm panel is the same:对话框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件确认框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact hide <序号> --commit
In other words, whether it is off or degraded, pick never fails silently — it separates "the panel never opened" from "the human really cancelled" (which takes longer and answers 已取消). The 选择 /redact pick · hint in nodes' tail appears only when allowDialogs: true and tuiDialogs is available, so it never advertises a path you have switched off.
3. Usage
3.1 /redact subcommands
/redact addresses sessions by session id (--session <id>), not by log path; without --session the target is the current session. Its argument hint in the / menu is [list|nodes|pick|scan|hide|plan|apply|verify|purge|rollback|undo] [plan.json] [--lines <n>] [--session <id>] [--commit].
nodes / hide / pick read and write the live session's in-memory surface, so they only accept the current session; pointing --session at another one errors out instead of quietly showing the current session.
| Subcommand | Syntax | Output / behaviour |
|---|---|---|
list |
/redact list |
Sessions by descending size, folded into one line: 共 N 个会话(按体积): + segments of <first 8 chars of id> <size> (human-readable, e.g. 1.5MB / 805.4KB / 551B) joined by |, with (当前) appended to the current session's segment; the budget is 200 display cells, so trailing entries collapse into …另M个 and the line normally ends with 用 /redact nodes 看当前会话. ⚠️ With enough sessions the budget runs out and even that trailing hint is cut to 用 /redact nodes …. This is also the default when no subcommand is given |
scan |
/redact scan <plan.json> [--session <id>] |
Locate only, one line: 会话 <first 8 chars of id> · 共 N 行 · 命中 M 行:<line numbers> · 未改动任何文件. At most 8 hit line numbers are listed; beyond that it appends …共M directly (e.g. 命中 14 行:2,3,4,5,6,7,8,9…共14). With 0 hits the :<line numbers> part is absent. A plan carrying substitutions[].lines adds · 已按 substitutions[].lines 限定. The count includes the header row. ⚠️ It uses only substitutions[].find as probes — a plan containing only setFields/blankLines cannot be located and reports 0 hits |
nodes |
/redact nodes [page|full] |
Lists the current session's tool/result surface nodes, newest first. Each entry reads [index] line 轮T tool size; a single-page listing looks like 共 3 个 tool/result(最新在前):[1] 7 轮3 tool-2 149B | [2] 5 轮2 tool-1 149B | [3] 3 轮1 tool-0 149B 全文 /redact nodes full · 隐藏 /redact hide <序号>. Paged: it splits on the display-cell budget, not a fixed item count (a fixed count would leave "the few that no longer fit" permanently invisible); with several pages the head becomes 共 N 个 tool/result(最新在前)第1/7页:, a non-final page continues 续 /redact nodes 2(还有17条) ·, and the last page drops the "continue" part, keeping only 全文 … · 隐藏 …; a single page omits 第x/y页 and ends the head with :. Indices stay continuous across pages (20 nodes measured at 7 pages with indices 1..20 and no gaps), so an index seen on any page can be fed straight to hide; an out-of-range page silently falls back to the last page. T is the tool/result event's own data.turn (- when absent); the tool name is resolved by looking the result's message.source.callId back up in the tool/call events (unresolved shows (未知工具)); sizes ≥1KB render as 12.3KB. nodes full (or --full) writes the complete list to %TEMP%\dsh-redact-nodes-<session id>.txt, one row per node like [1] 轮 3 tool-2 149B 行 7 {"cmd":"very-long-command-line-2","query":"SYNTHETIC-…"} → /redact hide 1 --commit (the command line is flattened by oneLine() and truncated to argsCells; with 0 the whole segment is omitted and no placeholder is left), and answers 完整清单已写入 <path>(共 N 条,序号可直接 /redact hide <序号>). The body is never shown (the complete identification story is in §3.2); the listing is remembered per session and is what /redact hide <序号> refers to. ⚠️ Other sessions are explicitly refused: nodes 只能列出当前会话的节点(surface 是活会话的内存状态) · 其它会话请用 /redact apply 处理其日志. When allowDialogs: true and tuiDialogs is available the tail gains 选择 /redact pick · (either one missing suppresses it) |
pick |
/redact pick |
Choose a node with the arrow keys in a TUI panel — the right answer to "show me the whole list and let me select". It is not subject to the 200-cell single-line limit (the dialog is rendered in the TUI's own chrome). ⚠️ Off by default: pick's first gate is allowDialogs (default false, see §2.3/§2.5); while it is off the command returns pick 的模态对话框默认关闭(它会让 TUI 键盘卡住,且 approval 挂起时必然复现)· 用 /redact nodes 看编号,再 /redact hide <序号> --commit · 确要启用请设 allowDialogs: true 并自行承担风险 and never touches the dialog service; the second gate is dialogTimeoutMs: 0 (对话框已被配置禁用(dialogTimeoutMs: 0)· …); only the third probes the service (当前前端没有对话框服务 tuiDialogs · …). Once enabled it still shows metadata only: it runs select (a multi-line list whose labels read [2] 行 5 轮 2 · tool-1 · 149B, with that call's command line as the entry's description on the next row; with argsCells: 0 the description is not set at all), then confirm for a second confirmation (title 隐藏 [2] 行 5 · tool-1 · 149B?, message 隐藏后从下一轮请求起模型不再看到它(磁盘字节仍在)。该节点会被永久遮蔽,无法还原。, buttons 隐藏 / 取消), and on confirmation performs the same surface replacement as hide. Success: 已隐藏 [2] 行 5 · tool-1 · 下一轮请求起模型不再看到 · 磁盘字节仍在(会话关闭后用 apply 清理). "The panel never opened" and "the human cancelled" are reported separately: a human cancel (Esc / timeout, ≥ 50 ms on the plugin's clock) yields 已取消; a cancel that arrives within 50 ms means the host did not admit this row → 对话框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact nodes 看列表,或 /redact nodes full 导出到文件, and the same for confirmation → 确认框没有弹出(0ms 内直接返回取消)· 大概率是宿主未接纳本行(启动时序)· 改用 /redact hide <序号> --commit (the fix is §2.5). Current session only (pick 只作用于当前会话;其它会话请用 /redact apply 重写日志。). At most 60 entries per invocation: beyond that the title spells it out — 选择要隐藏的节点(共 70 个,仅列最新 60 个;其余用 /redact hide <序号>); otherwise it is 选择要隐藏的节点(共 N 个,最新在前). Both dialogs carry timeoutMs: dialogTimeoutMs (default 15000). A throwing dialog call yields 对话框调用失败:<reason>; picking an id that does not exist yields 无效的选择:<id> |
hide |
/redact hide <index|plan.json> [--commit], /redact hide --lines <line> [--commit] |
Current session only, three ways to locate targets (see below): ① <index> refers to entry N of the most recent /redact nodes; ② <plan.json> finds the tool/result surface nodes whose data.message contains any substitutions[].find; ③ --lines <line> targets by log line number (exactly the number nodes printed). Without --commit it only dry-runs (试算:将隐藏 N 个节点(seq 5)· 加 --commit 执行, or 试算:未命中任何 tool/result 节点; nothing appended, nothing on disk touched); with --commit it appends one replacement node per target and reports 已隐藏 N 个节点 · 下一轮请求起模型不再看到 · 磁盘字节仍在(会话关闭后用 apply 清理) (or 未命中任何 tool/result 节点,未做改动 when nothing matched). ⚠️ When several targets are queued and one fails midway, the already-landed work is reported honestly: 第 2 个节点(seq 2)的消息形态不受支持(content 里没有可替换的文本块),未做改动 · 已有 1 个节点被永久遮蔽(seq 1),该变更已生效且不可撤销,建议重开会话. Disk bytes unchanged; effective from the next request |
plan |
/redact plan <plan.json> [--session <id>] |
Dry run, writes nothing, one line: 试算 OK(未写盘) · <id8> · 行 删x/空x/改x/换x · 帧 留x/写x · 自检通过. The /重编号x and /移除x parts are appended only when those counts are >0 (e.g. 行 删0/空0/改0/换1 · 帧 留1/写3) |
apply |
/redact apply <plan.json> [--session <id>] [--allow-live] |
Rewrites in place, one line: records the file revision → reads it → plans → re-checks the revision (any append in between refuses the whole run) → writes the quarantine backup → commits → purges derived caches. The success line now leads with the backup and its "still contains the original" warning (only the first 200 cells survive): 已脱敏 sess-bet · 备份 session.v3.jsonl.zstd.quarantine-<timestamp>(仍含原文,确认无误后自行删除) · 行 删0/空0/改0/换0 · 帧 留1/写1 · 缓存清理 0 · 另有 7 处需自行处理(见文档 安全模型); under --allow-live against the current session it also carries · 重开会话后内存历史才更新. Refusing the current session also leads with the way out: 拒绝改写在用会话 sess-alp(写句柄仍持有该日志) · 加 --allow-live 可在本会话内执行(之后需重开会话) · 或切到其它会话后执行 /redact apply <计划.json> --session sess-alp. Under --allow-live a torn tail causes a refusal (the write handle caches a truncation offset computed for the pre-rewrite layout, which risks silent corruption) |
verify |
/redact verify [--session <id>] |
One-line self-check. Pass: ✓ sess-alp · 帧 4 · 行 9 · 头部合法 · seq 密集 · 引用合法 · 读取端可打开; fail: ✗ <id8> · 帧 N · 行 N · 问题:<reason> (with kind also error; a reference problem reports the reference check's reason, otherwise the torn-tail offset / seq gap / parse-failure count). A damaged frame header yields 日志无法解析:<reason> instead of an exception |
purge |
/redact purge [--session <id>] |
One line: 已清理缓存 N 个 · <id8>; when copies this tool will not touch remain, it appends · 仍有 N 处本工具不动:<first path> 等 (paths only, never their content). A deletion failure never changes the command's outcome — it only appends (失败 N:<first reason>) after the count |
rollback |
/redact rollback <turns> [--session <id>] [--commit] |
Turn-based rollback, one line: counts the turn/end boundaries and truncates the last N complete turns (1 turn when the count is omitted). Dry run: 会话 sess-rol · 共 3 轮 · 回退 1 轮 → 保留 2 轮 · 删 2 行(自第 6 行起截断) · 试算未写盘,加 --commit 执行; --commit writes and continues with · 备份 <name>(仍含原文,确认无误后自行删除) · 缓存清理 N, then · 重开会话后生效 or · 下次打开该会话即为回退后状态 depending on whether it is the current session. Refusing the current session: 拒绝截断在用会话 sess-rol · 加 --allow-live 可在本会话内执行(之后需重开会话) · 或切到其它会话后执行 rollback 1 --session sess-rol. At least one turn must remain; a log with no turn/end is refused outright |
undo |
/redact undo [--session <id>] [--commit] |
Reverts the last redaction, one line: finds the newest quarantine backup for that log. Dry run: sess-bet · 备份 session.v3.jsonl.zstd.quarantine-<timestamp>(271B)· 当前 271B · 试算未写盘,加 --commit 恢复; --commit restores it and continues with · 已恢复(已校验) · 撤销前状态另存 <name> · 缓存清理 N (the current state is first saved aside as <log>.before-undo-<timestamp>). The backup is validated before it is installed: frames decodable, header legal, seq dense, references legal, no torn tail, and no fewer events than the current log; a backup that fails is refused with the current log left untouched — 拒绝恢复:备份 <name> 不合格(<reason>) · 当前日志未改动(185B) · 可改用更早的 .quarantine-* 或 .before-undo-* 备份手工恢复. Refusing the current session: 拒绝恢复在用会话 sess-u(写句柄仍持有该日志) · 加 --allow-live 可在本会话内执行(之后需重开会话) · 或切到其它会话后执行 /redact undo --session sess-u |
Why every output is a single line (the rendering constraint)
dsh-tui never shows a command's returned text directly: it runs cleanRenderText(text, COMMAND_RESULT_CELLS) first, where COMMAND_RESULT_CELLS = 200 (inside @deepseek-harness-tui/dsh-tui: the constant is at lib/types/screens/Chat.js:120, the call site in the same file at :1082), implemented in lib/types/dsh-adapter/sanitize.js:
const flat = withoutAnsi.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/\s+/g, ' ').trim();
if (stringWidth(flat) <= maxCells)
return flat;
let out = '';
for (const ch of flat) {
if (stringWidth(out + ch) > maxCells - 1)
break;
out += ch;
}
return `${out}…`;
Two things happen at once, and neither can be worked around:
- All whitespace is flattened:
\n,\t, and runs of spaces all become a single space — multi-line text becomes one line here, always. - It is truncated at 200 display cells with a trailing
…. Width is measured in terminal cells, and a CJK character counts as 2, so a Chinese notification really only has room for ~100 characters.
This is a hard constraint, not a style choice — the renderer will not make an exception for a command that would like a few more rows. So the plugin now does the same thing to itself first: index.js carries a clamp(s, 200) with identical rules (CJK counts as 2, overflow ends in …), and the command registration wraps the whole handler in clampResult():
handler: (invocation) => clampResult(handler(invocation)),
In other words, every output is already squeezed into 200 cells before it leaves the handler — it never depends on the renderer to do the cutting. So every subcommand's normal output is now a single line, fields separated by ·, most important information first (only a few long error branches are still newline-joined internally — see below).
So use this tool on that assumption:
- Do not expect readable multi-line formatting. Anything past 200 cells is cut off, tail first. Even
apply/rollbacksuccess lines can be cut off after the backup information, because quarantine file names are long. (That is also why the success wording now leads with the backup and its "still contains the original, delete it yourself" reminder.) - Do not plan around copying a long string out of a notification. The notification has been flattened and truncated; a long path or line number may already be gone and is awkward to select anyway. This is exactly why
hide <index>exists: after/redact nodes, hiding the newest entry is just/redact hide 1 --commit— not one character copied from a notification. list/nodes/scandeliberately show fewer entries than they could (…另N个/…共M): better to list less than to have a critical line number truncated away.- A few long error branches (refusing to rewrite the current session,
未知子命令, a missing plan file) are still newline-joined strings internally; they areclamped to 200 cells and flattened by the renderer just the same, so what you see on screen is always one line.
[!TIP] The one exception is
/redact pick, and it exists precisely to escape this constraint. Its dialog goes through the TUI's own panel (ctx.tuiDialogs), rendered in the TUI's chrome with the TUI owning the keyboard — it never passes throughcleanRenderText, so it can be multi-line, arrow-key navigable, and neither flattened nor truncated. To pick from the complete list usepick; to locate something inside a single-line notification usenodes+hide <序号>. The two are complementary, and neither ever shows the tool result's body (index / line / turn / tool / size only, plus the command line insidepick). Note thatpickis off by default (allowDialogs: false, see §2.5) — the exception only exists once you enable it.
Details, all taken from the implementation:
rollbackandundoare dry runs by default — they need an explicit--committo write, as doeshide.rollback/undo, likeapply, refuse to act on the session you are currently using unless--allow-liveis given;hide/pickare the exact opposite — they only act on the current session, error out when--sessionpoints elsewhere, and do not accept--allow-liveat all.nodesis paged;nodes fullis an export. It lists the current session'stool/resultsurface nodes, newest first (the one that just tripped a guardrail is almost always the latest). Paging splits on the display-cell budget, not a fixed item count — a fixed count would make "the few that no longer fit" permanently invisible — and indices stay continuous across pages, so an index seen on any page can be fed straight tohide <序号>.nodes fullwrites the complete list to%TEMP%\dsh-redact-nodes-<session id>.txt(the file name uses the full id so two sessions cannot overwrite each other), every row ending in→ /redact hide <n> --commit, with the command line truncated toargsCells(0= no command line at all). It never shows the body, and it does not expose the internalseq(seqappears only inhide's dry-run and midway-failure wording; the listing file and the panel use the index / line number, because those are what you can feed back into a command).pickis the right way to "see it all, then choose", but it is off by default and depends on the TUI row. The gates run in this order:allowDialogs(defaultfalse→ the "off by default" explanation) →dialogTimeoutMs === 0→ctx.get('tuiDialogs'). It soft-probes that service — by default deliberately not ininject, so a missing service never parks this plugin in a waiting state;picksimply returns the graceful-degradation message. The price is that in the worst startup order the panel can silently fail to open (the plugin turns a < 50 ms cancel into a dedicated error). To actually use the panel you needallowDialogs: trueplus the row-levelinject(see §2.5). The service contract lives in@deepseek-harness-tui/dsh-tui'slib/types/dsh-adapter/dialogs.d.ts:ctx.tuiDialogsis aTuiDialogRuntime extends Serviceexposingselect/confirm/input; every method validates its request (untrusted data on the render path) and, when the request is malformed, only warns and resolves with the cancelled value (undefined/false) — it never throws, because "a dialog must never take the plugin or the TUI down". The same file declaresDIALOG_DEFAULT_TIMEOUT_MS = 30000(the fallback when neithersignalnortimeoutMsis given); this plugin passes an explicittimeoutMs: dialogTimeoutMsto both dialogs (default 15000; the old 120000 is gone — see §2.3 and §2.5).selectresolves the chosenidorundefinedon cancel;confirmresolves a boolean,falseon cancel — this plugin treats both as "cancelled", except that a cancel in under 50 ms is not a human cancel: it means the host did not admit this row (the admission race), and the plugin reports that separately (see §2.5). The runtime also bounds every request (TITLE_CELLS/LABEL_CELLS= 120 cells,MESSAGE_CELLS= 400,MAX_OPTIONS= 100), which is why the plugin keeps titles near 90 cells and caps options at 60 — so the host never silently trims away information you need.Put the match text in
plan.json, never on the command line. The command registers withrecordInput: false, sorawInputnever enters the session log — but the frontend's own input history (~/.dsh-tui/history.jsonl) does not honour that flag, so anything you type may still persist there.--session <id>is a session id. If a session has bothsession.v2.jsonl.zstdandsession.v3.jsonl.zstd, only the highest version is targeted.Resolution walks
<root>/<project>/<id>/and picks the highestsession.vN.jsonl.zstd; on failure:找不到会话 <id> 的日志.Arguments are split on
"..."/'...'/ whitespace, and outer quotes are stripped — so a path containing spaces just needs quoting (/redact apply "C:\my plans\plan.json"). Relative paths still resolve against the host process's cwd, so absolute paths are recommended.hidehandlestool/resultnodes only (user and assistant messages are skipped), and the replacement text is always the configuredplaceholder, so a plan'sreplacehas no effect onhide. It locates targets three ways:<index>— entry N of this session's most recent/redact nodes, where1is the newest. That listing lives in memory only and is kept per session: runninghide 1before anynodesreports还没有节点列表,请先运行 /redact nodes, an out-of-range index reports序号 99 超出范围(当前 1-2), and it is lost when the host process restarts.<plan.json>— a substring test over the serialiseddata.message, using onlysubstitutions[].find(every other key is ignored).--lines <line>— by log line number, i.e. exactly the numbernodesprinted (row 1 is the header, so an event withseqN is row N + 2). It accepts a line spec:--lines 7,--lines 3-5,--lines 3,5-7. This form needs no plan.json at all, so not one character of the original has to land in a file.- With none of the three it reports
用法:/redact hide <序号|plan.json> 或 /redact hide --lines <行号>;--linesmust carry a value (--lines --commitis parsed as a bare flag, i.e. as if it were absent). Pointing--sessionat another session reportshide 只作用于当前会话;其它会话请用 /redact apply 重写日志。
--allow-livemust be a bare flag.--allow-live yesstores the string'yes', fails the=== truecheck, and is still refused.An unknown subcommand returns
未知子命令 <cmd>plus usage; with neither anagentnor--sessionyou get无法确定目标会话,请加 --session <id>. The usage string itself is one line too:用法:/redact list|nodes|pick|verify|purge|undo(可加 --session <id>) · scan|plan|apply <计划.json> · hide <序号|计划.json|--lines 行号> · rollback <轮数> · 写操作需 --commit;匹配文本写在计划文件里,别写命令行.
3.2 Identification metadata: telling which node is which
The most common complaint is "I simply cannot tell which message needs retracting or editing". So nodes / nodes full / pick now all carry the same identification metadata — index / log line / turn / tool name / size / that call's command line. The tool result's body never appears (that is this tool's promise), but those six fields together are enough to recognise the target.
Four real samples (all from synthetic fixtures, never a real session; samples 3 and 4 are the exception — pick only reaches the dialog once allowDialogs: true is set):
1) the /redact nodes one-line notification (newest first; each cell is [index] line 轮T tool size)
共 3 个 tool/result(最新在前):[1] 7 轮3 tool-2 149B | [2] 5 轮2 tool-1 149B | [3] 3 轮1 tool-0 149B 全文 /redact nodes full · 隐藏 /redact hide <序号>
2) the listing file written by /redact nodes full (one row per node, ending in a copy-ready hide command)
[1] 轮 3 tool-2 149B 行 7 {"cmd":"very-long-command-line-2","query":"SYNTHETIC-SEARCH-TERM-… → /redact hide 1 --commit
[2] 轮 2 tool-1 149B 行 5 {"cmd":"very-long-command-line-1","query":"SYNTHETIC-SEARCH-TERM-… → /redact hide 2 --commit
3) a /redact pick option: the label carries the identification, the description carries the command line
[1] 行 7 轮 3 · tool-2 · 149B
{"cmd":"tool-2","q":"Q-2"}
4) the /redact pick confirmation panel (it reads the chosen node back to you)
隐藏 [2] 行 5 · tool-1 · 149B?
Where each field comes from (all inside collectNodes() in index.js):
| Field | Source | When unavailable |
|---|---|---|
Index [N] |
Position in surface.nodes, newest first — the very numbering /redact hide <index> uses |
Always present |
Line <line> |
The tool/result event's seq + 2 (row 1 is the header, so an event with seq N is log row N+2); this is what --lines <line> consumes |
Always present |
Turn <T> |
The tool/result event's own data.turn — use it to line the entry up with the turn you see in the UI |
Rendered as - |
| Tool name | data.name from the tool/call event found via message.source.callId |
(未知工具) |
| Size | JSON.stringify(event.data.message).length, human-readable (1.5KB / 551B) |
? when serialisation fails |
| Command line | data.arguments of that same tool/call event (only a string counts; any other shape is treated as absent), flattened by oneLine(): control characters become spaces, whitespace is collapsed, then truncated to argsCells display cells |
The segment is dropped entirely (nodes full leaves no placeholder, pick sets no description) |
How to use it: read the turn first to narrow down which round of the conversation it was, then the command line to confirm the action, then the index to act (/redact hide <index> --commit) — nothing to copy, and no original text dragged into your command history.
[!TIP] The command line is what makes an entry recognisable, but it can itself contain sensitive search terms — hence
argsCells: default120display cells, lower is more conservative, and0shows no command line at all (not even a…survives in the listing file or the panel). Note also that the one-linenodesnotification never contains a command line (only index / line / turn / tool / size), so this switch only affects thenodes fullfile andpick'sdescription.
[!NOTE] The turn comes from the
tool/resultevent's owndata.turn; only the tool name and the command line need a lookup in thetool/callevent with the samecallId. Also note the field order differs on purpose: anodes fullrow is "turn first, line later" (轮 3 tool-2 149B 行 7 …) while thenodesnotification is "line first, turn later" ([1] 7 轮3 tool-2 149B) — the notification's 200-cell budget is tighter, and the line number is the first-hand input for--lines, so it comes first.
3.3 The full plan.json schema
The same plan file works for the in-TUI /redact and the offline dsh-redact.
| Key | Type | Meaning |
|---|---|---|
substitutions |
[{ find, replace?, path?, lines? }] |
find must be a non-empty string. replace omitted means '', i.e. delete the text. path is a dot-path prefix limiting which strings are touched; lines limits the row range. All string values are walked recursively, but protected keys are skipped |
setFields |
[{ line, path, value }] |
line is a 1-based logical row number (never 1); path must already exist, otherwise 第 N 行上找不到路径 ...; value may be any JSON value |
blankLines |
line spec, e.g. "830-834" |
Replaces every string value on those rows with the placeholder (from placeholder in the TUI path; default [已移除]) |
dropLines |
line spec | Deletes those rows. A trailing suffix is allowed directly; a middle drop requires renumber: true and is otherwise refused |
renumber |
true / omitted |
Permits middle deletion: renumbers seq and remaps surfaceOp.startSeq/endSeq, sourceEventSeqs, data.shadowedSeqs, data.shadowedRange. A reference to a deleted row is refused, with advice to use blankLines |
keepTorn |
true / omitted |
Defaults to false: a torn (partially written) trailing frame is dropped on rewrite — inspect reports whether one exists under "尾部残帧起点". true keeps those bytes verbatim |
Line spec format: "812", "812-830", "812,900,1024-1100" (comma-separated, ranges allowed). Line numbers are 1-based logical JSONL rows, where row 1 is the header.
Protected keys (skipped by substitutions, refused by setFields): type, seq, time, surfaceOp, sourceEventSeqs, toolCallId, callId, role, id. They carry structure, ordering, and cross-references.
Row 1 (the header) can never be dropped, blanked, or rewritten.
[!WARNING] Unknown or misspelled keys are silently ignored — the engine reads only the keys above. A typo like
"substitions"raises no error and does nothing, while looking like success. Always dry-run before applying, and always verify afterwards.
/redact hide <plan.json> uses only substitutions[].find and ignores every other key, so one plan can serve both hide and apply; hide --lines <line> and hide <index> do not need a plan file at all.
3.4 A worked plan.json
Everything below is placeholder text. In real use,
findis the text you want gone — and that file is itself sensitive; delete it when you are done.
{
"substitutions": [
{ "find": "PLACEHOLDER-SECRET-VALUE", "replace": "[已移除]" }
],
"setFields": [
{ "line": 812, "path": "data.message.content.0.text", "value": "[已移除]" }
],
"blankLines": "830-834"
}
How to fill it in:
- Find the hit rows with
dsh-redact inspect <log> --match-file needle.txt— put the text inneedle.txt, not on the command line;inspectprints line numbers only and never echoes the text. - Inspect the row's JSON shape with
dsh-redact paths <log> --line 812— it lists paths and types only (data.message.content.0.text = string(len=26)), no values. Copy a path intosetFields[].path. - Blank whole rows with
blankLines, target one field withsetFields, or sweep text everywhere withsubstitutions.
3.5 Offline CLI
When the session is not running, the offline CLI is the easier path (the .mjs also runs as node lib/engine.mjs <subcommand>; the registered binary name is dsh-redact). It takes a log file path, not a session id.
| Subcommand | Syntax | Meaning |
|---|---|---|
inspect |
dsh-redact inspect <log> [--frames] [--match-file <f> | --match <s>] |
Structure stats: file, byte size, complete frames, torn-tail offset, logical rows, frames without checksums; --frames adds a per-frame table (index, byte range, plain bytes, rows, logical row range); --match-file / --match add hit count, hit line numbers, and the frames involved. Line numbers and counts only |
paths |
dsh-redact paths <log> --line <n> [--no-keys] |
Prints that row's JSON structure (path + type + length), never values; --no-keys hides obj |
…
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.