手动上下文编辑器(manual-context)
DeepSeek Harness 插件。它做两件事:
- 手动上下文自动注入 —— 自动检测「手动上下文」文件夹里的内容,
每次对话前对照当前上下文,把尚未出现的条目插入到「用户本轮输入」之前、历史上下文之后。
- 对话历史编辑 —— 在 Web 里直接编辑历史对话(系统提示词 / 用户输入 / 模型输出 / 工具调用输入输出),
修改状态按会话保存,压缩上下文后依然保留。
1. 手动上下文文件夹
默认扫描两个位置(都会自动创建):
| 顺序 | 位置 | 用途 |
|---|
| 1 | <会话工作区>/manual-context | 随项目走的上下文 |
| 2 | $DSH_HOME/manual-context(本机为 $DSH_HOME\manual-context) | 全局上下文 |
也可以用环境变量 DSH_MANUAL_CONTEXT_DIRS 覆盖(分号分隔的绝对路径)。
文件夹里每个 *.md / *.txt 文件是一个条目,文件名即标题。
新建位置
面板左上角的下拉框决定新条目写进哪个根目录:
| 选项 | 实际路径 | 作用范围 |
|---|
| 项目(当前工作区) | <会话工作区>/manual-context | 只对该项目生效 |
| 全局(DSH 根目录) | $DSH_HOME/manual-context | 对所有项目生效 |
也可以不经 UI,直接把 .md / .txt 文件丢进这两个目录,下次对话前就会被扫描到。
新建时可以直接选注入类型(用户输入 / 模型输出 / 工具调用 / 工具返回),
它会写进文件头部的 frontmatter;API 上就是 create-file 的 role 字段。
注入类型(role)
每个条目可以在文件头部用 frontmatter 声明它注入成什么类型的消息,默认是「用户输入」:
---
role: assistant
---
我已经检查过配置文件,没有问题。
| role | 注入方式 | 模型看到的角色 |
|---|
user(默认) | 合成一条 user 消息插到本轮用户输入之前,不写会话日志 | user |
assistant | 追加 assistant/message | assistant |
tool-call | 追加 assistant/message,内嵌 tool-call 块 | assistant |
tool-result | 追加 tool/result | user(tool-result 块) |
可用字段:
| 字段 | 适用 role | 说明 |
|---|
role | 全部 | user / assistant / tool-call / tool-result |
tool | tool-call | 工具名,默认 manual_tool |
args | tool-call | 工具参数,JSON 字符串 |
callId | tool-call / tool-result | 省略时自动生成;tool-result 省略则按条目顺序与前面最近的未配对 tool-call 配对 |
isError | tool-result | true 时标记为失败的工具结果 |
surface 只认 system/message / user/message / assistant/message / tool/result 四种消息事件,
所以非 user 类型必须真正写进会话日志;追加发生在 pre-step 之后、本轮用户输入写入之前,
于是模型看到的顺序是「历史上下文 → 模拟的工具/模型消息 → 本轮用户输入」。
在 UI 里选中条目后,用文件名旁边的下拉框即可切换类型,保存时会写回 frontmatter。
两种编辑方式
选中条目后,编辑框上方可以在 文本 与 JSON 之间切换:
| 方式 | 编辑内容 |
|---|
| 文本 | 整个文件(含 frontmatter),改完由宿主按 role 同步 |
| JSON | 结构化字段,保存时由宿主组装 frontmatter |
JSON 模式的形状:
{
"role": "tool-call",
"tool": "read_file",
"args": "{\"path\":\"config.json\"}",
"callId": "",
"isError": false,
"body": "我来读取配置文件"
}
role 为 user、isError 为 false、空字符串都会被省略,写出的文件与文本模式完全一致;
- frontmatter 键大小写不敏感:
callId / callid / CALL_ID 解析后都归一成 callId;
- JSON 解析失败或顶层不是对象时会在面板报错,不写文件。
一个文件里混排多种角色(段标记)
文件正文可以用 `` 切成多段,每段各自决定角色:
```markdown
这段没有标记,继承 frontmatter 的 role
请始终用中文回答
好的,我会用中文。
我来读取配置文件
{"port": 3080}
```
规则:
- 连续的多行标记组成一个「段头」,其后到下一个段头之间的正文属于该段;
- 段头里可以写 `role` / `tool` / `args` / `callId` / `isError`,段标记优先,其次继承文件级 frontmatter;
- 没有任何标记时整篇就是一段,老写法完全兼容;
- 编辑框上方的五个按钮(用户输入 / 模型输出 / 思维链 / 工具调用 / 工具返回)会在光标处插入对应的 ``。
注入时按段展开:同一个文件里 user 段会合并成一条 user 消息,
其余段各自落地成对应角色的真实消息。实例:
```text
user │ 看看配置
assistant │ 好的,我会用中文。 ←
assistant │ 我来读取配置文件 + [tool-call read_file] ←
user │ [tool-result manual-call-…] ←
user │ … 请始终用中文回答 ← 两个 user 段合成
user │ 看看配置 ← 本轮真实输入
```
注入时机与去重
- 只在每个 turn 的第一个 step(
agent/pre-step)检查,也就是「每次对话前」。
- 判断依据是会话当前的模型可见历史:先看有没有
id 等于
manual-context:<条目 hash> 的消息(非 user 类型靠这个),
再看文本里有没有 <dsh-manual-context file="…" hash="…"> 标记(user 类型,兼容旧版)。
- 内容变了(hash 变)或条目被压缩掉了,就会重新注入;已经在上下文里就不重复插入。
- user 类型合成一条 plugin 来源的 user 消息插在用户输入之前;其余类型追加成真实消息事件。
2. 历史编辑
点击会话头部的 「✎ 上下文编辑」 按钮打开面板,第二个标签页列出当前模型可见的全部消息。
编辑一条消息时,插件追加一个带 surfaceOp: { op: 'replace', startSeq, endSeq } 的事件,
遮蔽原节点 —— 这与 Harness 内置压缩使用完全相同的机制,因此:
- 模型之后的请求立即看到编辑后的内容;
- 编辑本身是会话日志的一部分,可被压缩、可被 replay;
- 不需要也不允许写入自定义的事件类型(外部插件事件无法通过持久化读取校验,会造成会话无法重建)。
替换时保留原消息的 role 与 source,只换内容:
| 目标节点 | 承载事件 | 编辑后的角色 |
|---|
| 用户输入 | user/message | 仍是 user |
| 模型输出 | user/message(消息的 role 仍为 assistant) | 仍是 assistant |
| 工具输出 | user/message(消息里仍是 tool-result 块) | 仍是 user + tool-result 块,toolCallId 保持不变 |
| 系统提示词 | — | 不可改写,见下 |
运行时并不强制「事件类型 == 消息角色」:surface 只要求事件类型可替换,
所以用 user/message 事件承载一条 role: 'assistant' 的消息是合法的,
模型历史里它依然以模型输出出现。
为什么系统提示词改不了:agent-loop 的 SystemPromptProjection 会在每次请求组装时
把 surface 头节点重新写回渲染结果,任何替换下一轮都会被覆盖。
面板里因此不再提供该按钮。想长期生效,请把手动上下文条目的 role 设为 user,它每轮都会注入。
两种编辑方式
编辑框上方可以在 文本 与 JSON 之间切换:
| 方式 | 内容 | 适合 |
|---|
| 文本 | 消息的纯文本;工具调用等非文本块自动保留 | 改措辞 |
| JSON | 直接编辑 content blocks 数组 | 增删工具调用、改工具名与参数、构造精确结构 |
JSON 模式的 content 必须是数组,保存时按原样写入;角色与 source 仍然保持。
JSON 解析失败会在面板里直接报错,不会写入会话。
保持原位
替换会生成一个新节点(surface 用"遮蔽"而不是"删除"),但:
- 新节点在 surface 里的位置与原节点相同,模型看到的历史顺序不变;
- 面板保存后会自动把选中项切到新节点上(接口返回
replacedSeq),列表位置和选中状态都不跳。
删除消息
列表项右侧的 ✕ 可以把一条消息从模型可见上下文里移除;点工具栏的 多选 会出现复选框,
勾选后 删除选中 可批量删除。
实现上是用一条 空内容的 system/message 遮蔽目标节点 —— 空内容不会投影出任何消息
(deriveEventMessage 对空 content 返回 null),从而达到删除效果,且不破坏节点顺序。
surface 第 0 个系统提示词不允许删除。
手动上下文条目同样支持单条 ✕ 删除与多选批量删除。
思维链(reasoning)
模型输出的 `reasoning` 块与正文是分开的,面板会单独展示:
- 详情里显示「思维链(reasoning 块)」区块;
- 进入文本编辑态会多出一个思维链输入框,可以单独改写;
- 只改正文时思维链原样保留(早期版本会把它丢掉,已修);
- JSON 模式下 `{"type":"reasoning","text":"…"}` 就是普通块,可任意增删。
手动上下文侧,段标记支持 ``,注入成一条带 `reasoning` 块的 assistant 消息。
主对话流不会变(重要)
编辑通过 surface 遮蔽实现:新节点在 surface 里的位置与原节点相同,
但主对话流的消息列表是按原始事件日志渲染的,不应用 surface 遮蔽
(`dsh-client-ui-conversation` 只在重建系统提示词时读 surface)。
| 视图 | 看到的内容 |
|---|
| 主对话流 | 原始内容 |
| 本面板的历史列表 | 编辑后的内容,也是模型实际看到的 |
| 模型请求 | 编辑后的内容 |
判断改动是否生效,以本面板和模型的实际回答为准,不要看主对话流。
修改状态保存位置
$DSH_HOME/manual-context-edits/<会话ID>.json
按会话保存,记录了每个被编辑节点的 seq、替换节点 seq、原类型与编辑后的文本。
面板上的「已编辑 / 清除标记」即读写这份档案,因此压缩上下文后修改状态不会丢失。
安装 / 启用
插件通过 profile 的 patch 层挂载:
# $DSH_HOME/profiles/web/cordis.patch.yml
- insert:
- id: manual-context
name: '@dsh-external/manual-context'
包名需要在 profile 的 node_modules 下可解析。本机用 junction 指向本目录:
New-Item -ItemType Junction \
-Path "$env:DSH_HOME\profiles\web\node_modules\@dsh-external\manual-context" \
-Target "<插件目录>"
新增 bundle 需要重启 dsh web 才会进入客户端模块图。
HTTP 接口
浏览器端通过同源路由 /manual-context 与宿主通信:
| 方法 | 参数 | 作用 |
|---|
| GET | ?op=status&sessionId= | 目录、条目列表、注入状态、编辑档案 |
| GET | ?op=file&sessionId=&id= | 读取单个条目 |
| GET | ?op=history&sessionId= | 当前模型可见的历史消息 |
| POST | {op:'save-file'} / {op:'create-file', root:0|1} / {op:'delete-file'} | 条目写操作(root 选根目录) |
| POST | {op:'save-edit', seq, text} / {op:'forget-edit', seq} / {op:'clear-edits'} | 历史编辑写操作 |
| POST | {op:'append-message', kind, text, toolName?, toolInput?, callId?, isError?} | 追加新消息(kind: user / assistant / tool-call / tool-result) |
排查
| 现象 | 原因 | 处理 |
|---|
面板报 Unexpected end of JSON input | 宿主没注册路由,响应体为空 —— 通常是插件尚未加载,或 HTTP 服务名写错 | 见下:dsh 注册路由的服务叫 webServer;确认 patch 条目存在后重启 dsh web |
面板报 宿主插件未挂载 /manual-context 路由(HTTP 404) | 同上 | 同上 |
| 改了插件源码后页面文案更新了、接口仍 404 | 刷新页面只重读磁盘上的浏览器 bundle,宿主进程里的插件模块不会重载 | 必须重启 dsh web 进程(Ctrl+C 后重新运行),不是刷新页面 |
| 条目全部显示「未注入」 | 该会话没有工作区(cwd),只扫描到了 $DSH_HOME/manual-context | 在项目目录里开会话 |
| 编辑历史时提示「已被压缩」 | 目标节点已不在当前 surface 上 | 属预期:被压缩掉的消息无法再被替换 |
注意:dsh 0.1.6 注册 HTTP 路由的服务名是 ctx.webServer(@deepseek-ai/dsh-host-webserver)。
代码里若写 ctx.inject(['httpServer', …]),回调永远不会执行且没有任何报错,表现为路由 404。
开发
无需构建步骤:宿主与浏览器端都是可直接运行的 ES 模块/CommonJS bundle。
node --check src/index.js # 逐文件语法检查
node --test tests/store.test.mjs tests/host.test.mjs
文件
| 文件 | 职责 |
|---|
src/index.js | 插件入口,挂载注入钩子与 HTTP 路由 |
src/store.js | 手动上下文文件夹的发现、创建与读写 |
src/inject.js | agent/pre-step 注入 |
src/history.js | surface 替换编辑与按会话的修改状态存储 |
src/http.js | /manual-context 路由 |
src/client.js | 浏览器端面板(window.__ModuleLoader__ bundle) |
src/freeze.js | 消息冻结与文本/工具调用提取 |