dsh-agent-persona 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to this project are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.1] - 2026-11-08
8
+
9
+ 这一版修了两个实际会踩到的问题,另外把文档和安装脚本里过时的说法清理了一遍。
10
+
11
+ ### Fixed
12
+
13
+ - **用「独占提示词」的 Agent 预设时,人设一个字都发不出去。** 这种预设会声明 `complete: true`,DSH 在
14
+ 组装提示词的最后一步会把其他 section 全丢掉,本插件那一节也没了,而且不报错。现在插件在组装结束后
15
+ 看一眼结果:发现只剩预设那一段、人设没进去,就把人设补进去——追加接在后面,替换整段换掉。
16
+ 插件卸载时会把改动还原。
17
+ - 顺手把这层包装写稳了:宿主不让改方法时只跳过这个功能,不影响插件其他部分;插件卸载后即使别的插件
18
+ 也包过一层,这里也不会继续往提示词里塞人设。
19
+ - **「提示词」不再慢一拍。** 以前打开着这个标签去发消息,标签里还是上一轮的 system prompt,得手动点
20
+ 刷新或者整页刷新才更新。现在一轮跑完会自动重新读一次;正在编辑人设时不会动你的草稿。
21
+
22
+ ### Tests
23
+
24
+ - 补了这些情况的测试:独占提示词下追加 / 替换 / 空正文、普通提示词不重复塞、包装还原、宿主报错时原样
25
+ 返回,以及「插件装好后确实会包上 `assemble`」这一层。
26
+
27
+ ### Changed
28
+
29
+ - 文档和安装脚本跟上现状:重启说明补了 systemd 托管的做法,插件详情页「界面与功能」和四个界面入口写
30
+ 清楚了;开发文档里的断言数量、`dsh.bundle.patch` 字段名、远程方法数(16 个)一并改正。
31
+
7
32
  ## [0.2.0] - 2026-11-07
8
33
 
9
34
  插件页自己会管配置了,图标、显示名和最新的 DSH 也对上了。
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  dsh plugin --profile web add dsh-agent-persona
23
23
  ```
24
24
 
25
- 装完**重启一次 `dsh web`**。profile 的 bundles 只在启动时读取,插件行就是从这里挂进去的。重启后设置左栏会多出 **Agent 人设**;`~/.dsh/dsh-agent-persona/state.json` 里也有加载心跳,写明了加载的文件、人设存哪、一共几条。
25
+ 装完**重启一次 `dsh web`**。profile 的 bundles 只在启动时读取,插件行就是从这里挂进去的。如果你的宿主由 systemd 之类的服务托管(比如本机的 `dsh-web-wood`),改用 `sudo systemctl restart dsh-web-wood`,别自己 kill 掉进程再手起。重启后设置左栏会多出 **Agent 人设**;`~/.dsh/dsh-agent-persona/state.json` 里也有加载心跳,写明了加载的文件、人设存哪、一共几条。
26
26
 
27
27
  升级、卸载同样是 `dsh plugin --profile web update|remove dsh-agent-persona`,重启一次后生效。人设文件不会跟着删,也不会被覆盖。仓库里另有本地安装脚本:`bash scripts/install.sh`(Windows 用 `scripts/install.ps1`)。
28
28
 
@@ -54,7 +54,7 @@ dsh plugin --profile web add dsh-agent-persona
54
54
 
55
55
  关掉「在设置左栏显示「Agent 人设」」只是从设置导航里隐藏,插件详情页仍在,可以继续改人设、随时开回来。插件列表里中文名是「Agent 人设」、英文名是「Agent Persona」,说明文字来自 `locale/`,图标是包根的 `icon.svg`,不跟随主题色。
56
56
 
57
- ![插件详情页头部:Agent 人设图标、版本号 v0.2.0 与中文描述](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/plugin-detail-icon.png)
57
+ ![插件详情页头部:Agent 人设图标、版本号与中文描述](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/plugin-detail-icon.png)
58
58
 
59
59
  ## 人设列表与编辑
60
60
 
@@ -83,7 +83,7 @@ dsh plugin --profile web add dsh-agent-persona
83
83
  选中一条会话后,顶部有 **人设 / 提示词 / 管理** 三个面板:
84
84
 
85
85
  - **人设**:命中了哪条人设、依据是哪几条规则、正文渲染结果,以及就地编辑器;
86
- - **提示词**:这条会话**实际发出去的** system prompt,可切换渲染/源码、复制、刷新;
86
+ - **提示词**:这条会话**实际发出去的** system prompt,可切换渲染/源码、复制、刷新。一轮对话结束时它会自己重新读一次,不用手动点刷新;如果你正开着编辑框,它不会把你的改动冲掉;
87
87
  - **管理**:和设置页同一套卡片,就地新建、启用、编辑、排序、复制、删除。
88
88
 
89
89
  旁边的只读会话记录弹窗只显示你真正敲进去的内容,插件注入的「看起来像用户消息」的内容会折叠成一行提示;长消息可单条展开,也能跳到最新。
@@ -131,6 +131,11 @@ dsh plugin --profile web add dsh-agent-persona
131
131
 
132
132
  正文里可以用 `{{model}}` 和 `{{cwd}}`,渲染时替换成当前值;其它 `{{...}}` 会被去掉,不会让整个组装报错。
133
133
 
134
+ 还有一种情况得说清楚:有些 Agent 预设的人设段会声明 `complete: true`,意思是「这一节就是完整提示词」。
135
+ DSH 组装到最后会把别的 section 全扔掉,插件写的那节也一样,所以这种预设下人设看起来配好了、实际没发出去。
136
+ 现在插件会在组装结束后检查一遍:要是只剩预设那一段、人设没进去,就把它补进去——追加接在后面,替换则
137
+ 整段换成这条人设。换句话说,遇到这种预设时上面「别人的身份提示优先」就不作数了,那段提示词是唯一的位子。
138
+
134
139
  ## 数据与安全
135
140
 
136
141
  ```
package/README_EN.md CHANGED
@@ -22,7 +22,7 @@ Requires DSH `0.2.0-rc.2` or newer; this release is tested on `0.2.2-rc.1` (full
22
22
  dsh plugin --profile web add dsh-agent-persona
23
23
  ```
24
24
 
25
- Then **restart `dsh web` once**. Profile bundles are read at startup, and the plugin registers itself from there.
25
+ Then **restart `dsh web` once**. Profile bundles are read at startup, and the plugin registers itself from there. If a service manager runs the host (this machine's `web` profile is the systemd unit `dsh-web-wood`), restart that instead: `sudo systemctl restart dsh-web-wood`. Don't kill the process and start `dsh web` by hand.
26
26
 
27
27
  After the restart the settings sidebar has an **Agent 人设** page, and `~/.dsh/dsh-agent-persona/state.json` contains a load heartbeat saying which file was loaded, where personas are stored, and how many there are.
28
28
 
@@ -56,7 +56,7 @@ The plugin detail page has a new **界面与功能** (Interface and features) bl
56
56
 
57
57
  Turning off **在设置左栏显示「Agent 人设」** only hides the page from the settings navigation. The plugin detail page remains, so you can keep editing and turn the entry back on at any time. In the plugin list the display name is **Agent 人设** in Chinese and **Agent Persona** in English; the description comes from `locale/`, and the icon is `icon.svg` at the package root (it does not follow the theme color).
58
58
 
59
- ![Plugin detail header: the Agent Persona icon, v0.2.0, and the Chinese description](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/plugin-detail-icon.png)
59
+ ![Plugin detail header: the Agent Persona icon, version number, and the Chinese description](https://raw.githubusercontent.com/Awoodwhale/dsh-agent-persona/main/docs/images/plugin-detail-icon.png)
60
60
 
61
61
  ## Personas: list and editor
62
62
 
@@ -85,7 +85,7 @@ With **保存人设版本历史** on, every body save appends a diff to `persona
85
85
  Select a session and the top of the view has **人设 / 提示词 / 管理** (persona / prompt / manage):
86
86
 
87
87
  - **人设**: which persona matched, which rules matched, the rendered body, and an inline editor;
88
- - **提示词**: the system prompt **actually sent** for this session, with rendered/source toggle, copy, and refresh;
88
+ - **提示词**: the system prompt **actually sent** for this session, with rendered/source toggle, copy, and refresh. It also reloads itself when a turn finishes, so you usually don't need the refresh button — and it won't clobber an editor you have open;
89
89
  - **管理**: the same cards as the settings page, for creating, enabling, editing, reordering, duplicating, and deleting in place.
90
90
 
91
91
  Next to it is a read-only conversation log: it shows only what you actually typed, and plugin-injected content that looks like a user message is collapsed into a one-line notice. Long messages can be expanded individually, and you can jump to the latest one.
@@ -133,6 +133,8 @@ Of the four match types, only `完全一致` (exact) normalizes paths (`/a/proje
133
133
 
134
134
  The body supports `{{model}}` and `{{cwd}}`, substituted with current values at render time. Other `{{...}}` placeholders are removed rather than failing the whole assembly.
135
135
 
136
+ One more case worth spelling out: some Agent presets declare `complete: true` on their persona section, meaning "this section is the whole prompt". At the end of assembly DSH throws away every other section, including the one this plugin writes, so the persona looks configured but never reaches the model. The plugin now checks the result after assembly: if only the preset's section is left and the persona is missing, it puts the persona in — appended after it for Append, replacing the whole text for Replace. With such a preset the "someone else's identity wins" rule above no longer applies: that section is the only slot there is.
137
+
136
138
  ## Data and security
137
139
 
138
140
  ```
package/lib/client.js CHANGED
@@ -883,7 +883,11 @@
883
883
  * session it is showing; otherwise it keeps rendering the one it was first opened for.
884
884
  */
885
885
  componentDidUpdate(previous) {
886
- if (this.sessionId() !== sessionIdOf(previous)) void this.load();
886
+ if (this.sessionId() !== sessionIdOf(previous)) {
887
+ void this.load();
888
+ return;
889
+ }
890
+ if (previous.running === true && this.props.running === false && this.state.editing !== true) void this.load();
887
891
  }
888
892
  sessionId() {
889
893
  return sessionIdOf(this.props);
@@ -1144,6 +1148,10 @@
1144
1148
  );
1145
1149
  }
1146
1150
  }
1151
+ const PersonaViewLive = (props) => {
1152
+ const running = props.useSession((snapshot) => snapshot?.running === true);
1153
+ return h(PersonaView, { ...props, running });
1154
+ };
1147
1155
  const dotState = (persona) => {
1148
1156
  if (persona === void 0) return "off";
1149
1157
  if (!persona.enabled) return "off";
@@ -3011,7 +3019,7 @@ ${state.proposal.trim()}
3011
3019
  id: "agent-persona",
3012
3020
  order: 30,
3013
3021
  label: () => "\u4EBA\u8BBE"
3014
- }, PersonaView));
3022
+ }, PersonaViewLive));
3015
3023
  };
3016
3024
  const dropTabSeat = () => {
3017
3025
  if (!tabSeatMounted) return;
package/lib/index.d.ts CHANGED
@@ -263,6 +263,30 @@ export declare const applyPersonaMode: (assembly: any, { mode, deploymentPrefix
263
263
  deploymentPrefix: any;
264
264
  mode: any;
265
265
  }) => any;
266
+ /**
267
+ * Fold a persona into a prompt that one `complete: true` section owns.
268
+ *
269
+ * An agent preset may declare its own persona with `complete: true`; the registry then hands back that single
270
+ * section as the whole system prompt, and it does so *after* the `system-prompt/assemble` waterfall — so no
271
+ * listener can add a section of its own, and this plugin's section is discarded before it ever renders. The
272
+ * only place a persona can still live is inside that section.
273
+ * @param assembly - an assembled prompt.
274
+ * @param persona - `{ text, mode }` of the persona that claims this assembly, or `undefined`.
275
+ * @returns the assembly with the persona folded in, or the input when nothing applies.
276
+ */
277
+ export declare const applyCompletePersona: (assembly: any, persona: any) => any;
278
+ /**
279
+ * Reach the prompts a `complete: true` section owns.
280
+ *
281
+ * `assemble()` is wrapped instead of listening to the waterfall because the registry collapses the section
282
+ * list *after* dispatching it: the waterfall's sections are overwritten by the complete one. Disposal restores
283
+ * the original method, so unloading the plugin leaves no trace.
284
+ * @param systemPrompt - the prompt registry service.
285
+ * @param readPersona - resolves `{ text, mode }` for one assembly context, or `undefined`.
286
+ * @param warn - diagnostic sink.
287
+ * @returns the disposer that restores `assemble`.
288
+ */
289
+ export declare const bridgeCompletePrompt: (systemPrompt: any, readPersona: any, warn?: () => void) => () => void;
266
290
  /**
267
291
  * Session ids the user has archived. The registry is asked first; the workspace
268
292
  * store file is the fallback, and an unreadable store simply means "none".
package/lib/index.js CHANGED
@@ -1236,6 +1236,81 @@ export const applyPersonaMode = (assembly, { mode, deploymentPrefix }) => {
1236
1236
  }
1237
1237
  return changed ? { ...assembly, sections: next } : assembly;
1238
1238
  };
1239
+ /**
1240
+ * Fold a persona into a prompt that one `complete: true` section owns.
1241
+ *
1242
+ * An agent preset may declare its own persona with `complete: true`; the registry then hands back that single
1243
+ * section as the whole system prompt, and it does so *after* the `system-prompt/assemble` waterfall — so no
1244
+ * listener can add a section of its own, and this plugin's section is discarded before it ever renders. The
1245
+ * only place a persona can still live is inside that section.
1246
+ * @param assembly - an assembled prompt.
1247
+ * @param persona - `{ text, mode }` of the persona that claims this assembly, or `undefined`.
1248
+ * @returns the assembly with the persona folded in, or the input when nothing applies.
1249
+ */
1250
+ export const applyCompletePersona = (assembly, persona) => {
1251
+ const text = typeof persona?.text === 'string' ? persona.text.trim() : '';
1252
+ if (text === '')
1253
+ return assembly;
1254
+ const sections = assembly?.sections;
1255
+ if (!Array.isArray(sections) || sections.length !== 1)
1256
+ return assembly;
1257
+ const [only] = sections;
1258
+ // Our own section surviving means this is a normal prompt: the waterfall already placed the persona, and
1259
+ // folding it in again would print it twice.
1260
+ if (only?.name === SECTION_NAME)
1261
+ return assembly;
1262
+ const current = typeof only?.text === 'string' ? only.text : '';
1263
+ // `replace` takes the prompt over, which is the honest reading of "replace" when the preset owns every word;
1264
+ // `append` keeps that prompt and puts the persona right after it, where section order 1 would have been.
1265
+ // Trailing whitespace is dropped first so a complete prompt ending in a newline does not gain a blank line.
1266
+ const body = current.trimEnd();
1267
+ const next = persona.mode === 'replace' ? text : body === '' ? text : `${body}\n\n${text}`;
1268
+ return next === current ? assembly : { ...assembly, sections: [{ ...only, text: next }] };
1269
+ };
1270
+ /**
1271
+ * Reach the prompts a `complete: true` section owns.
1272
+ *
1273
+ * `assemble()` is wrapped instead of listening to the waterfall because the registry collapses the section
1274
+ * list *after* dispatching it: the waterfall's sections are overwritten by the complete one. Disposal restores
1275
+ * the original method, so unloading the plugin leaves no trace.
1276
+ * @param systemPrompt - the prompt registry service.
1277
+ * @param readPersona - resolves `{ text, mode }` for one assembly context, or `undefined`.
1278
+ * @param warn - diagnostic sink.
1279
+ * @returns the disposer that restores `assemble`.
1280
+ */
1281
+ export const bridgeCompletePrompt = (systemPrompt, readPersona, warn = () => { }) => {
1282
+ const original = systemPrompt?.assemble;
1283
+ if (typeof original !== 'function')
1284
+ return () => { };
1285
+ // The wrapper is disposable state, not a permanent tombstone: after the plugin unloads it must stop folding
1286
+ // even when another plugin has wrapped `assemble` on top of it and the original method cannot be restored.
1287
+ let active = true;
1288
+ const patched = async (context) => {
1289
+ const assembly = await original.call(systemPrompt, context);
1290
+ if (!active)
1291
+ return assembly;
1292
+ try {
1293
+ return applyCompletePersona(assembly, readPersona(context));
1294
+ }
1295
+ catch (error) {
1296
+ warn('could not fold the persona into a complete system prompt', error);
1297
+ return assembly;
1298
+ }
1299
+ };
1300
+ try {
1301
+ systemPrompt.assemble = patched;
1302
+ }
1303
+ catch (error) {
1304
+ // A host that freezes or seals the service must only cost this feature, never the whole plugin.
1305
+ warn('could not wrap assemble(); personas will not reach a complete prompt', error);
1306
+ return () => { };
1307
+ }
1308
+ return () => {
1309
+ active = false;
1310
+ if (systemPrompt.assemble === patched)
1311
+ systemPrompt.assemble = original;
1312
+ };
1313
+ };
1239
1314
  /** Read one event's plain text out of the shapes DSH session logs use. */
1240
1315
  const eventText = (event) => {
1241
1316
  const parts = [];
@@ -1895,6 +1970,15 @@ export function apply(ctx, config = {}) {
1895
1970
  catch (error) {
1896
1971
  warn(`section "${SECTION_NAME}" is already registered; keeping the live one`, error);
1897
1972
  }
1973
+ // A preset that owns the whole prompt (`complete: true`) never renders our section, so the persona has to be
1974
+ // folded into the section that preset does own. Registered through the effect, so unloading restores
1975
+ // `assemble` instead of leaving a wrapper behind.
1976
+ ctx.effect(() => bridgeCompletePrompt(ctx.systemPrompt, (context) => {
1977
+ const persona = resolvePersona(currentStore().personas, subjectOf(context?.agent)).persona;
1978
+ if (persona === undefined || persona.text.trim() === '')
1979
+ return undefined;
1980
+ return { text: sanitizePersona(persona.text, warn), mode: persona.mode };
1981
+ }, warn), 'agent-persona.complete-bridge');
1898
1982
  // The store is this plugin's instruction source, so no tool call may write it. The guard is monotonic: once
1899
1983
  // it refuses a call nothing later can allow it. Registered through the effect, so unloading the plugin
1900
1984
  // removes it instead of leaving a process-wide refusal behind.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agent-persona",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Workspace personas for DeepSeek Harness (DSH): create many system-prompt personas and scope each one by workspace path or session id (exact / prefix / regex / contains), managed from a Web settings page with Markdown editing and AI tuning.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -3,7 +3,7 @@
3
3
  # pwsh -File scripts/install.ps1
4
4
  # pwsh -File scripts/install.ps1 -Profile my-profile
5
5
  # pwsh -File scripts/install.ps1 -Profile web -Target .
6
- # pwsh -File scripts/install.ps1 -Profile web -Target dsh-agent-persona@0.1.1
6
+ # pwsh -File scripts/install.ps1 -Profile web -Target dsh-agent-persona@0.2.1
7
7
  param(
8
8
  [string]$Profile = "web",
9
9
  [string]$Target = "dsh-agent-persona"
@@ -58,14 +58,19 @@ Installed into profile "$Profile" (this checkout is v$Version).
58
58
 
59
59
  dsh.profile.bundles is read at STARTUP, so restart the host now:
60
60
 
61
- dsh web
61
+ dsh web # if you started the host by hand
62
+ # if a service runs it, restart that service instead; on Linux hosts that
63
+ # use systemd the unit is often named like dsh-web-<user>
62
64
 
63
65
  Then check it loaded:
64
66
 
65
67
  Get-Content "$State"
66
- # 设置 -> Agent 人设 the management page
67
- # 对话页顶部「人设」 the per-session view
68
- # 设置 -> 通用设置 carries nothing from this plugin
68
+ # 设置 -> Agent 人设 完整管理页(可在插件页的「界面与功能」里隐藏这个入口)
69
+ # 对话页顶部「人设」 当前会话的人设 / 实际发送的提示词
70
+ # 插件 -> dsh-agent-persona 图标、README 和「界面与功能」开关;设置入口被隐藏后从这里回去
71
+ # dsh-better-sidebar 右侧 「Agent 人设」卡片(装了那个插件才有)
72
+
73
+ The switches on the plugin page and in Settings write the same prefs, so either place works.
69
74
 
70
75
  Personas live in one file, shared by every profile unless you override config.storePath:
71
76
 
@@ -4,7 +4,7 @@
4
4
  # bash scripts/install.sh # published package, profile "web"
5
5
  # bash scripts/install.sh my-profile # another profile
6
6
  # bash scripts/install.sh web . # this checkout (development)
7
- # bash scripts/install.sh web dsh-agent-persona@0.1.1 # a pinned version
7
+ # bash scripts/install.sh web dsh-agent-persona@0.2.1 # a pinned version
8
8
  set -euo pipefail
9
9
 
10
10
  PROFILE="${1:-web}"
@@ -56,14 +56,18 @@ Installed into profile "${PROFILE}" (this checkout is v${VERSION}).
56
56
 
57
57
  dsh.profile.bundles is read at STARTUP, so restart the host now:
58
58
 
59
- dsh web # or stop the running one and start it again
59
+ dsh web # if you started the host by hand
60
+ sudo systemctl restart dsh-web-wood # if systemd runs it (use your unit name)
60
61
 
61
62
  Then check it loaded:
62
63
 
63
64
  cat "${STATE}"
64
- # 设置 → Agent 人设 the management page
65
- # 对话页顶部「人设」 the per-session view
66
- # 设置 → 通用设置 carries nothing from this plugin
65
+ # 设置 → Agent 人设 完整管理页(可在插件页的「界面与功能」里隐藏这个入口)
66
+ # 对话页顶部「人设」 当前会话的人设 / 实际发送的提示词
67
+ # 插件 → dsh-agent-persona 图标、README 和「界面与功能」开关;设置入口被隐藏后从这里回去
68
+ # dsh-better-sidebar 右侧 「Agent 人设」卡片(装了那个插件才有)
69
+
70
+ The switches on the plugin page and in Settings write the same prefs, so either place works.
67
71
 
68
72
  Personas live in one file, shared by every profile unless you override config.storePath:
69
73
 
package/src/client.ts CHANGED
@@ -1001,7 +1001,13 @@ const readPrefs = () => {
1001
1001
  * session it is showing; otherwise it keeps rendering the one it was first opened for.
1002
1002
  */
1003
1003
  componentDidUpdate(previous) {
1004
- if (this.sessionId() !== sessionIdOf(previous)) void this.load()
1004
+ if (this.sessionId() !== sessionIdOf(previous)) {
1005
+ void this.load()
1006
+ return
1007
+ }
1008
+ // A finished turn may have produced a new system prompt while the tab stayed open. Reload so
1009
+ // 「提示词」 is current; skip while the inline editor is open so typing is never clobbered.
1010
+ if (previous.running === true && this.props.running === false && this.state.editing !== true) void this.load()
1005
1011
  }
1006
1012
 
1007
1013
  sessionId() {
@@ -1287,6 +1293,16 @@ const readPrefs = () => {
1287
1293
  }
1288
1294
 
1289
1295
 
1296
+ /**
1297
+ * The conversation tab receives the session snapshot hooks; the sidebar card does not. Subscribe
1298
+ * here so a turn that finishes while 「提示词」 is open can refresh itself instead of showing the
1299
+ * previous turn's prompt until the reader reloads the page.
1300
+ */
1301
+ const PersonaViewLive = (props) => {
1302
+ const running = props.useSession((snapshot) => snapshot?.running === true)
1303
+ return h(PersonaView, { ...props, running })
1304
+ }
1305
+
1290
1306
  const dotState = (persona) => {
1291
1307
  if (persona === undefined) return 'off'
1292
1308
  if (!persona.enabled) return 'off'
@@ -3406,7 +3422,7 @@ const since = (timestamp) => {
3406
3422
  id: 'agent-persona',
3407
3423
  order: 30,
3408
3424
  label: () => '人设',
3409
- }, PersonaView))
3425
+ }, PersonaViewLive))
3410
3426
  }
3411
3427
  const dropTabSeat = () => {
3412
3428
  if (!tabSeatMounted) return
package/src/index.ts CHANGED
@@ -1223,6 +1223,75 @@ export const applyPersonaMode = (assembly, { mode, deploymentPrefix }) => {
1223
1223
  return changed ? { ...assembly, sections: next } : assembly
1224
1224
  }
1225
1225
 
1226
+ /**
1227
+ * Fold a persona into a prompt that one `complete: true` section owns.
1228
+ *
1229
+ * An agent preset may declare its own persona with `complete: true`; the registry then hands back that single
1230
+ * section as the whole system prompt, and it does so *after* the `system-prompt/assemble` waterfall — so no
1231
+ * listener can add a section of its own, and this plugin's section is discarded before it ever renders. The
1232
+ * only place a persona can still live is inside that section.
1233
+ * @param assembly - an assembled prompt.
1234
+ * @param persona - `{ text, mode }` of the persona that claims this assembly, or `undefined`.
1235
+ * @returns the assembly with the persona folded in, or the input when nothing applies.
1236
+ */
1237
+ export const applyCompletePersona = (assembly, persona) => {
1238
+ const text = typeof persona?.text === 'string' ? persona.text.trim() : ''
1239
+ if (text === '') return assembly
1240
+ const sections = assembly?.sections
1241
+ if (!Array.isArray(sections) || sections.length !== 1) return assembly
1242
+ const [only] = sections
1243
+ // Our own section surviving means this is a normal prompt: the waterfall already placed the persona, and
1244
+ // folding it in again would print it twice.
1245
+ if (only?.name === SECTION_NAME) return assembly
1246
+ const current = typeof only?.text === 'string' ? only.text : ''
1247
+ // `replace` takes the prompt over, which is the honest reading of "replace" when the preset owns every word;
1248
+ // `append` keeps that prompt and puts the persona right after it, where section order 1 would have been.
1249
+ // Trailing whitespace is dropped first so a complete prompt ending in a newline does not gain a blank line.
1250
+ const body = current.trimEnd()
1251
+ const next = persona.mode === 'replace' ? text : body === '' ? text : `${body}\n\n${text}`
1252
+ return next === current ? assembly : { ...assembly, sections: [{ ...only, text: next }] }
1253
+ }
1254
+
1255
+ /**
1256
+ * Reach the prompts a `complete: true` section owns.
1257
+ *
1258
+ * `assemble()` is wrapped instead of listening to the waterfall because the registry collapses the section
1259
+ * list *after* dispatching it: the waterfall's sections are overwritten by the complete one. Disposal restores
1260
+ * the original method, so unloading the plugin leaves no trace.
1261
+ * @param systemPrompt - the prompt registry service.
1262
+ * @param readPersona - resolves `{ text, mode }` for one assembly context, or `undefined`.
1263
+ * @param warn - diagnostic sink.
1264
+ * @returns the disposer that restores `assemble`.
1265
+ */
1266
+ export const bridgeCompletePrompt = (systemPrompt, readPersona, warn = () => {}) => {
1267
+ const original = systemPrompt?.assemble
1268
+ if (typeof original !== 'function') return () => {}
1269
+ // The wrapper is disposable state, not a permanent tombstone: after the plugin unloads it must stop folding
1270
+ // even when another plugin has wrapped `assemble` on top of it and the original method cannot be restored.
1271
+ let active = true
1272
+ const patched = async (context) => {
1273
+ const assembly = await original.call(systemPrompt, context)
1274
+ if (!active) return assembly
1275
+ try {
1276
+ return applyCompletePersona(assembly, readPersona(context))
1277
+ } catch (error) {
1278
+ warn('could not fold the persona into a complete system prompt', error)
1279
+ return assembly
1280
+ }
1281
+ }
1282
+ try {
1283
+ systemPrompt.assemble = patched
1284
+ } catch (error) {
1285
+ // A host that freezes or seals the service must only cost this feature, never the whole plugin.
1286
+ warn('could not wrap assemble(); personas will not reach a complete prompt', error)
1287
+ return () => {}
1288
+ }
1289
+ return () => {
1290
+ active = false
1291
+ if (systemPrompt.assemble === patched) systemPrompt.assemble = original
1292
+ }
1293
+ }
1294
+
1226
1295
  /** Read one event's plain text out of the shapes DSH session logs use. */
1227
1296
  const eventText = (event) => {
1228
1297
  const parts = []
@@ -1858,6 +1927,15 @@ export function apply(ctx, config = {}) {
1858
1927
  warn(`section "${SECTION_NAME}" is already registered; keeping the live one`, error)
1859
1928
  }
1860
1929
 
1930
+ // A preset that owns the whole prompt (`complete: true`) never renders our section, so the persona has to be
1931
+ // folded into the section that preset does own. Registered through the effect, so unloading restores
1932
+ // `assemble` instead of leaving a wrapper behind.
1933
+ ctx.effect(() => bridgeCompletePrompt(ctx.systemPrompt, (context) => {
1934
+ const persona = resolvePersona(currentStore().personas, subjectOf(context?.agent)).persona
1935
+ if (persona === undefined || persona.text.trim() === '') return undefined
1936
+ return { text: sanitizePersona(persona.text, warn), mode: persona.mode }
1937
+ }, warn), 'agent-persona.complete-bridge')
1938
+
1861
1939
  // The store is this plugin's instruction source, so no tool call may write it. The guard is monotonic: once
1862
1940
  // it refuses a call nothing later can allow it. Registered through the effect, so unloading the plugin
1863
1941
  // removes it instead of leaving a process-wide refusal behind.