dsh-prompt-customizer 0.4.2 → 0.5.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/lib/effective.js CHANGED
@@ -46,7 +46,7 @@ export function mergeConfig(globalCfg, ovr) {
46
46
  * 阶段模式匹配 —— bootstrap(未晋级)→ `bootstrap`,active(已晋级)→
47
47
  * `active`,compact(压缩后仍未晋级)→ `compaction`。每个阶段的注入集
48
48
  * 互相独立(各自的 order 空间),用于「每阶段独立名单 + 虚拟 order」。
49
- * 传入 `status`(promotion.status() 结果 `{ promoted, boundary }`),
49
+ * 传入 `status`(阶段投影 statusOf 的结果 `{ promoted, boundary }`),
50
50
  * 不再沿用旧的布尔 promoted(旧的 compaction 窗口被硬塞进 bootstrap 期,
51
51
  * 无法独立排序)。
52
52
  */
@@ -67,7 +67,7 @@ export function filterInjectByPhase(injectList, status) {
67
67
  * 走了阶段独立性:界面按三个阶段分别勾选,而引导期的勾选状态既反映不了静态
68
68
  * 名单里已隐藏的工具(显示成"可见"、模型其实看不见),往引导期名单里加一项又
69
69
  * 会让整个静态名单在该阶段失效(其它工具的隐藏一起蒸发)。
70
- * `status` 是 promotion.status() 的结果 `{ promoted, boundary }`。
70
+ * `status` 是阶段投影 statusOf 的结果 `{ promoted, boundary }`。
71
71
  */
72
72
  export function pickToolsFilter(toolsCfg, status) {
73
73
  if (!status.promoted) {
package/lib/index.js CHANGED
@@ -40,7 +40,7 @@ import os from 'node:os'
40
40
  import path from 'node:path'
41
41
  import { Config, NS } from './schema.js'
42
42
  import { mergeConfig, pickToolsFilter, pickSectionsForStatus, applyToolFilter, applySectionPolicy } from './effective.js'
43
- import { createPromotion, presetOfSession } from './promotion.js'
43
+ import { createPhaseProjection, presetOfSession, statusOf } from './promotion.js'
44
44
  import { createConfigStore, readLegacySection } from './store.js'
45
45
  import { createCatalog } from './catalog.js'
46
46
  import { registerVariables, listVariableNames } from './vars.js'
@@ -60,8 +60,7 @@ function dshHome() {
60
60
  return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
61
61
  }
62
62
 
63
- /** 收集并解析一个请求体(JSON),超过上限直接拒绝——路由是本进程私有的,上限只是防御。 */
64
- function readJsonBody(req, limit = 2 * 1024 * 1024) {
63
+ /** 收集并解析一个请求体(JSON),超过上限直接拒绝——路由是本进程私有的,上限只是防御。 */function readJsonBody(req, limit = 2 * 1024 * 1024) {
65
64
  return new Promise((resolve, reject) => {
66
65
  let size = 0
67
66
  const chunks = []
@@ -159,11 +158,16 @@ export function apply(ctx, entry = {}) {
159
158
  // 启动时剥掉并记一条日志(逐处列出被去掉的名字),不静默改用户文件。
160
159
  stripLegacyInclude(store, (message) => { try { ctx.logger?.info?.(message) } catch { /* logger 缺席 */ } })
161
160
 
162
- // 2) 晋级 tracker:增量维护每会话的 bootstrap/active 阶段(durable 事件
163
- // 推导,compaction 复位,subagent 恒已晋级)。
164
- const promotion = createPromotion()
165
- ctx.on('session/event', (session, event) => {
166
- try { promotion.observe(session, event) } catch { /* tracker 故障绝不影响事件流 */ }
161
+ // 2) 阶段投影:把 bootstrap/active 推导注册成宿主的会话投影 unit —— 宿主按会话
162
+ // 折叠(含 resume / reload 后从日志重建)并增量驱动,本插件不再自扫历史
163
+ // (`Session.events` 已在 0.1.2-alpha.4 移除,同步历史读在 0.1.6 已 deprecated;
164
+ // 见 promotion.js 顶部说明)。注册随本插件的 fiber 一起注销。
165
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
166
+ try {
167
+ projectionCtx.sessionProjections.register(createPhaseProjection())
168
+ } catch (error) {
169
+ try { ctx.logger?.warn?.(`prompt-customizer: 阶段投影注册失败(阶段视图回落为已晋级):${String((error && error.message) || error)}`) } catch { /* logger 缺席 */ }
170
+ }
167
171
  })
168
172
 
169
173
  // 单次降级告警:过滤逻辑出错时退回未加工的装配,绝不拖垮会话。
@@ -196,20 +200,30 @@ export function apply(ctx, entry = {}) {
196
200
  // 3) 装配目标解析(瀑布流与 assemble 强制覆盖共用):生效配置 = 显式预览
197
201
  // 提示 > 会话所属 agent 预设(字段级覆盖)> 全局默认。预览 hint 优先于
198
202
  // 会话事件推导的阶段,让同一份配置能渲染 bootstrap / compaction / active
199
- // 三种阶段视图。boundary 语义与 promotion.status 一致(-1 = 从未压缩)。
203
+ // 三种阶段视图。boundary 语义与阶段投影 statusOf 一致(-1 = 从未压缩)。
204
+ // 预览装配可携带 promptCustomizerPatch(UI 未保存草稿):只在内存里叠加
205
+ // 到目标层(overlayPatch),磁盘不动 —— 实时预览「所见即保存后所得」。
200
206
  const resolveTarget = (context) => {
201
207
  const raw = read()
208
+ const patch = context?.promptCustomizerPatch
209
+ const base = patch !== null && typeof patch === 'object' && !Array.isArray(patch)
210
+ ? overlayPatch(raw,
211
+ typeof context.promptCustomizerTarget === 'string' && context.promptCustomizerTarget.length > 0
212
+ ? context.promptCustomizerTarget
213
+ : undefined,
214
+ patch)
215
+ : raw
202
216
  const id = typeof context?.promptCustomizerPreset === 'string'
203
217
  ? context.promptCustomizerPreset
204
- : presetOfSession(context?.agent?.session)
205
- const cfg = mergeConfig(raw, raw.overrides?.[id])
218
+ : presetOfSession(ctx.get('sessionProjections'), context?.agent?.session)
219
+ const cfg = mergeConfig(base, base.overrides?.[id])
206
220
  const phaseHint = typeof context?.promptCustomizerPhase === 'string'
207
221
  ? context.promptCustomizerPhase
208
222
  : undefined
209
223
  const status = phaseHint === 'bootstrap' ? { promoted: false, boundary: -1 }
210
224
  : phaseHint === 'compaction' ? { promoted: false, boundary: 1 }
211
225
  : phaseHint === 'active' ? { promoted: true, boundary: -1 }
212
- : promotion.status(context?.agent)
226
+ : statusOf(ctx.get('sessionProjections'), context?.agent)
213
227
  return { raw, cfg, status }
214
228
  }
215
229
 
@@ -341,7 +355,38 @@ export function apply(ctx, entry = {}) {
341
355
  // 会抛 "cannot get property without inject"。
342
356
  const webserver = ctx.get('webServer')
343
357
  if (webserver) {
344
- const dispose = webserver.register({
358
+ // 连接信任闸(宿主 0.1.2-alpha.1 起):官方 /api 通道走 process 级 bootstrap
359
+ // token + 签名 Cookie,而直接注册在 webServer 上的自建路由不在其中 —— 宿主
360
+ // 契约要求先问过 connection.requestRejection:其 Host/Origin 围栏挡 DNS
361
+ // rebinding 与跨站调用,浏览器认证再挡未持 Cookie 的调用。本插件的路由会写盘
362
+ // (config/presets),必须过闸;闸本身不可用(connection 服务缺席)时保守
363
+ // 拒绝(fail-closed)——「没有认证面」不等于「无需认证」。
364
+ //
365
+ // 服务每次请求现读,绝不在 apply 时捕获:connection 由同 profile 的另一条
366
+ // entry 提供,entry 创建顺序不保证(实测冷启时本插件 apply 早于其提供,
367
+ // 捕获版会让整组路由永久 503);宿主自带路由(open-in-app 等)同款懒读。
368
+ const unauthenticated = (req, res) => {
369
+ const connection = ctx.get('connection')
370
+ if (typeof connection?.requestRejection !== 'function') {
371
+ writeJson(res, 503, { ok: false, error: 'connection service unavailable: prompt-customizer routes refuse to serve unauthenticated' })
372
+ return true
373
+ }
374
+ const rejection = connection.requestRejection(req)
375
+ if (rejection === undefined) return false
376
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
377
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
378
+ return true
379
+ }
380
+ /** 与 webserver.register 同形,统一在 handler 前挂信任闸(注册顺序与 disposer 不变)。 */
381
+ const register = (route) => webserver.register({
382
+ kind: route.kind,
383
+ path: route.path,
384
+ handler: (req, res) => {
385
+ if (unauthenticated(req, res)) return undefined
386
+ return route.handler(req, res)
387
+ },
388
+ })
389
+ const dispose = register({
345
390
  kind: 'exact',
346
391
  path: '/api/prompt-customizer/inventory',
347
392
  handler: async (req, res) => {
@@ -376,13 +421,24 @@ export function apply(ctx, entry = {}) {
376
421
  // 使用 standard agent,与清单一致);可选 `?phase=<bootstrap|compaction|
377
422
  // active>` 预览指定对话阶段的三态视图(省略时按晋级推导,只读路由下
378
423
  // 即 active / 静态视图)。
379
- const disposePreview = webserver.register({
424
+ // POST 形态(UI 实时预览):body { target?, patch?, phase? } —— patch 是
425
+ // UI 未保存的编辑草稿,只在内存里叠加(overlayPatch),绝不写盘。
426
+ const disposePreview = register({
380
427
  kind: 'exact',
381
428
  path: '/api/prompt-customizer/preview',
382
429
  handler: async (req, res) => {
383
430
  try {
384
- const { scopeKey, hintId, phase, scopeResolved } = await previewTarget(ctx, req)
385
- const preview = await buildPreview(ctx, scopeKey, hintId, phase, catalog)
431
+ let body = null
432
+ if (req.method === 'POST') {
433
+ try { body = await readJsonBody(req) } catch { body = null }
434
+ }
435
+ const { scopeKey, hintId, phase, scopeResolved } = await previewTarget(ctx, req, body)
436
+ const patch = body !== null && typeof body === 'object'
437
+ && body.patch !== null && typeof body.patch === 'object' && !Array.isArray(body.patch)
438
+ ? body.patch
439
+ : undefined
440
+ const patchTarget = typeof body?.target === 'string' && body.target.length > 0 ? body.target : undefined
441
+ const preview = await buildPreview(ctx, scopeKey, hintId, phase, catalog, patch, patchTarget)
386
442
  writeJson(res, 200, { ...preview, scopeResolved, phase })
387
443
  } catch (error) {
388
444
  writeJson(res, 500, {
@@ -396,7 +452,7 @@ export function apply(ctx, entry = {}) {
396
452
 
397
453
  // agent 预设枚举路由:直接读 roster 的 list()(未 memo 化,运行中新增
398
454
  // 的预设立即可见),给 UI 的目标选择器与预览选择器供货。
399
- const disposePresets = webserver.register({
455
+ const disposePresets = register({
400
456
  kind: 'exact',
401
457
  path: '/api/prompt-customizer/agent-presets',
402
458
  handler: async (_req, res) => {
@@ -423,7 +479,7 @@ export function apply(ctx, entry = {}) {
423
479
  // 来源预设的原样组成 + 我刚改好的定制,这才叫「保存的是我修改后的样子」。
424
480
  // 我们不再自己生成骨架:骨架带着 persona complete: true,宿主会在水流之后把
425
481
  // sections 还原成那一条段,段级定制在它上面根本不生效。
426
- const disposePresetsCreate = webserver.register({
482
+ const disposePresetsCreate = register({
427
483
  kind: 'exact',
428
484
  path: '/api/prompt-customizer/presets',
429
485
  handler: async (req, res) => {
@@ -486,7 +542,7 @@ export function apply(ctx, entry = {}) {
486
542
  // GET /config → { ok, config }(解析后含默认值)
487
543
  // POST /config/set → body { field, value };写入并返回最新 config
488
544
  // POST /config/unset → body { field };删除字段并返回最新 config
489
- const disposeConfig = webserver.register({
545
+ const disposeConfig = register({
490
546
  kind: 'exact',
491
547
  path: '/api/prompt-customizer/config',
492
548
  handler: async (_req, res) => {
@@ -511,14 +567,14 @@ export function apply(ctx, entry = {}) {
511
567
  })
512
568
  }
513
569
  }
514
- const disposeConfigSet = webserver.register({
570
+ const disposeConfigSet = register({
515
571
  kind: 'exact',
516
572
  path: '/api/prompt-customizer/config/set',
517
573
  handler: withFieldWrite(({ field, value }) => store.setField(field, value)),
518
574
  })
519
575
  ctx.effect(() => disposeConfigSet, 'prompt-customizer: config set route')
520
576
 
521
- const disposeConfigUnset = webserver.register({
577
+ const disposeConfigUnset = register({
522
578
  kind: 'exact',
523
579
  path: '/api/prompt-customizer/config/unset',
524
580
  handler: withFieldWrite(({ field }) => store.setField(field, undefined)),
@@ -529,7 +585,7 @@ export function apply(ctx, entry = {}) {
529
585
  // 无需卸载 / 重启(forceSections: false 时 assemble 包装直接放行宿主原样,
530
586
  // 瀑布流里空配置的过滤是无操作)。文件本身保留,避免卸载重装后迁移逻辑
531
587
  // 再次触发;之后要重新启用定制,把 forceSections 改回 true 或删除该行即可。
532
- const disposeConfigReset = webserver.register({
588
+ const disposeConfigReset = register({
533
589
  kind: 'exact',
534
590
  path: '/api/prompt-customizer/config/reset',
535
591
  handler: async (_req, res) => {
@@ -547,11 +603,10 @@ export function apply(ctx, entry = {}) {
547
603
  ctx.effect(() => disposeConfigReset, 'prompt-customizer: config reset route')
548
604
 
549
605
  // 批量保存路由:UI「统一保存按钮」的唯一写盘通道。body { target?, patch },
550
- // patch 只认业务字段(只认下列业务字段,含每阶段独立段名单),字段值为 null 表示
551
- // 删除(回退默认/全局)。target 缺省写全局顶层;target 为 agent 预设 id
552
- // 时写 overrides[id],清空后的空 override 连带删除,避免配置文件堆积空壳。
553
- const APPLY_FIELDS = ['sections', 'sectionsBootstrap', 'sectionsCompaction', 'replace', 'inject', 'tools']
554
- const disposeConfigApply = webserver.register({
606
+ // patch 只认业务字段(含每阶段独立段名单),字段值为 null 表示删除(回退
607
+ // 默认/全局)。target 缺省写全局顶层;target 为 agent 预设 id 时写
608
+ // overrides[id]。叠加语义与 /preview 的草稿预览共用 overlayPatch(一份实现)。
609
+ const disposeConfigApply = register({
555
610
  kind: 'exact',
556
611
  path: '/api/prompt-customizer/config/apply',
557
612
  handler: async (req, res) => {
@@ -563,26 +618,7 @@ export function apply(ctx, entry = {}) {
563
618
  return
564
619
  }
565
620
  const target = typeof body.target === 'string' && body.target.length > 0 ? body.target : undefined
566
- const base = { ...(store.raw() ?? {}) }
567
- const applyPatch = (obj) => {
568
- for (const field of APPLY_FIELDS) {
569
- if (!Object.hasOwn(patch, field)) continue
570
- if (patch[field] === null) delete obj[field]
571
- else obj[field] = patch[field]
572
- }
573
- }
574
- if (target === undefined) {
575
- applyPatch(base)
576
- } else {
577
- const overrides = { ...(base.overrides ?? {}) }
578
- const ovr = { ...(overrides[target] ?? {}) }
579
- applyPatch(ovr)
580
- if (Object.keys(ovr).length > 0) overrides[target] = ovr
581
- else delete overrides[target]
582
- if (Object.keys(overrides).length > 0) base.overrides = overrides
583
- else delete base.overrides
584
- }
585
- store.writeSection(base)
621
+ store.writeSection(overlayPatch({ ...(store.raw() ?? {}) }, target, patch))
586
622
  writeJson(res, 200, { ok: true, config: read() })
587
623
  } catch (error) {
588
624
  writeJson(res, 400, {
@@ -604,6 +640,39 @@ function withTimeout(value, ms, fallback) {
604
640
  ])
605
641
  }
606
642
 
643
+ /** 配置补丁认得的业务字段(与 UI 编辑域一一对应;含每阶段独立名单)。 */
644
+ const APPLY_FIELDS = ['sections', 'sectionsBootstrap', 'sectionsCompaction', 'replace', 'inject', 'tools']
645
+
646
+ /**
647
+ * 把一份补丁叠加到 raw 配置的目标层,返回新对象(不改原对象、不碰磁盘)。
648
+ * target 缺省 = 全局顶层;字符串 = overrides[id]。字段值为 null = 删除该字段
649
+ *(回退默认 / 全局);全空的 override 连带删除,避免配置堆积空壳。
650
+ * /config/apply 落盘与 /preview 的草稿叠加(实时预览,只读内存)共用这一份
651
+ * 语义 —— 两处行为永远一致。
652
+ */
653
+ function overlayPatch(raw, target, patch) {
654
+ const base = { ...(raw ?? {}) }
655
+ const applyPatch = (obj) => {
656
+ for (const field of APPLY_FIELDS) {
657
+ if (!Object.hasOwn(patch, field)) continue
658
+ if (patch[field] === null) delete obj[field]
659
+ else obj[field] = patch[field]
660
+ }
661
+ }
662
+ if (target === undefined) {
663
+ applyPatch(base)
664
+ } else {
665
+ const overrides = { ...(base.overrides ?? {}) }
666
+ const ovr = { ...(overrides[target] ?? {}) }
667
+ applyPatch(ovr)
668
+ if (Object.keys(ovr).length > 0) overrides[target] = ovr
669
+ else delete overrides[target]
670
+ if (Object.keys(overrides).length > 0) base.overrides = overrides
671
+ else delete base.overrides
672
+ }
673
+ return base
674
+ }
675
+
607
676
  /**
608
677
  * 宿主 authoring API(`agentPresets.copy`)抛错 → HTTP 状态码。
609
678
  *
@@ -735,61 +804,34 @@ async function resolveScopeFor(ctx, id) {
735
804
  * id 挂载失败时展示会回落 standard scope,但标记为 false,让 UI 明确警示
736
805
  * 「预览的不是该预设的原生装配」。
737
806
  */
738
- async function previewTarget(ctx, req) {
807
+ async function previewTarget(ctx, req, body = null) {
739
808
  const id = queryOf(req).get('scope') ?? undefined
740
809
  const resolved = id ? await resolveScopeFor(ctx, id) : await resolveStandardScope(ctx)
741
810
  const scopeKey = resolved ?? (id ? await resolveStandardScope(ctx) : undefined)
742
- // `?phase=`:bootstrap / compaction / active 三态预览;其它值一律回退实际推导。
743
- const rawPhase = queryOf(req).get('phase') ?? undefined
811
+ // `?phase=`(或 POST body.phase):bootstrap / compaction / active 三态预览;
812
+ // 其它值一律回退实际推导。
813
+ const rawPhase = (body !== null && typeof body === 'object' ? body.phase : undefined)
814
+ ?? queryOf(req).get('phase')
815
+ ?? undefined
744
816
  const phase = rawPhase === 'bootstrap' || rawPhase === 'compaction' || rawPhase === 'active'
745
817
  ? rawPhase
746
818
  : undefined
747
819
  return { scopeKey, hintId: id, phase, scopeResolved: resolved !== undefined }
748
820
  }
749
821
 
750
- /**
751
- * 按预览阶段合成一个最小伪会话 agent。预设原生的阶段裁剪插件(zero-tool
752
- * bootstrap / router-bootstrap / warmup 等)在 assemble 瀑布流里只读
753
- * `session.id / session.events / session.header / agent.options.model` 推导
754
- * 晋级状态并动态裁剪工具目录 —— 合成的 durable 事件让这些规则在只读预览
755
- * 里按所选阶段真实运行(而非挤在常驻视图):
756
- * - bootstrap:空事件(boundary -1,未晋级,首轮形态)
757
- * - compaction:一条 compaction/end(压缩后未重新晋级)
758
- * - active:一条晋级信号(assistant/message,event 形状见各预设的
759
- * compaction-epoch.mjs —— 只认 type 与 seq)
760
- */
761
- export function fakeAgentFor(phase, presetId) {
762
- const events = phase === 'compaction'
763
- ? [{ type: 'compaction/end', seq: 1 }]
764
- : phase === 'active'
765
- ? [{ type: 'assistant/message', seq: 1 }]
766
- : []
767
- return {
768
- session: {
769
- id: `prompt-customizer-preview-${phase}`,
770
- events,
771
- header: {
772
- delegationDepth: 0,
773
- agentPreset: presetId,
774
- cwd: process.cwd(),
775
- meta: {},
776
- },
777
- },
778
- options: { provider: '', model: '' },
779
- }
780
- }
781
-
782
822
  /**
783
823
  * 渲染某个 scope 的最终系统提示词:跑完整的装配瀑布流(所有插件的过滤、
784
824
  * 包括我们自己的都会生效),再把幸存的段拼成文本。这就是模型实际看到的内容。
785
825
  *
786
- * `phase` 给定时额外携带伪会话 agent,让预设原生的阶段裁剪规则(引导期
787
- * 收敛目录等)也按所选阶段真实运行;伪会话触发任何插件异常时降级为无
788
- * 会话装配(原生规则视为已晋级 / 全目录)并标记 degraded,预览永远可用。
789
- * 同时返回注册表原始目录总数 registryTotal,与模型可见目录形成对照
790
- * (如 PTC / Code Mode 把全目录包装成单一 run_code)。
826
+ * 装配是**纯 scope 的诊断装配(不带 agent)**:宿主的不变量契约明确「诊断
827
+ * 性装配不得携带 agent」—— agent-presets 的 roster 不变量见到未加入预设的
828
+ * agent 就 fail(抛异常),旧版伪会话每次都触发它,让每次阶段预览都降级。
829
+ * 当前 harness 的装配瀑布流里也没有读会话推导阶段的插件(阶段裁剪发生在
830
+ * agent-loop 层,不在 assemble 钩子),三个阶段视图完全由我们自己的
831
+ * promptCustomizerPhase hint 驱动。同时返回注册表原始目录总数 registryTotal,
832
+ * 与模型可见目录形成对照(如 PTC / Code Mode 把全目录包装成单一 run_code)。
791
833
  */
792
- async function buildPreview(ctx, scopeKey, hintId, phase, catalog) {
834
+ async function buildPreview(ctx, scopeKey, hintId, phase, catalog, patch, patchTarget) {
793
835
  // promptCustomizerPreset / promptCustomizerPhase 都是本插件的自定义装配
794
836
  // 字段:让我们的过滤器在这次装配里按该 agent 预设的 override 与指定阶段
795
837
  // 视图生效(AssembleContext 允许插件自定义字段)。
@@ -798,20 +840,18 @@ async function buildPreview(ctx, scopeKey, hintId, phase, catalog) {
798
840
  promptCustomizerPreset: hintId,
799
841
  promptCustomizerPhase: phase,
800
842
  // 请求预过滤视图(base):本插件的过滤器把进入过滤前的段 / 工具原文
801
- // 连同屏蔽 / 隐藏标记一起附在装配结果上,作为三个 Tab 的统一数据源。
843
+ // 连同屏蔽 / 隐藏标记一起附在装配结果上,作为面板两个栏的统一数据源。
802
844
  promptCustomizerBase: true,
803
845
  }
804
- let assembled
805
- let degraded = false
806
- try {
807
- assembled = await ctx.systemPrompt.assemble(
808
- phase ? { ...hint, agent: fakeAgentFor(phase, hintId) } : hint,
809
- )
810
- } catch {
811
- // 伪会话让某个预设插件抛错了:退回无会话装配,原生规则不参与本次预览。
812
- degraded = phase !== undefined
813
- assembled = await ctx.systemPrompt.assemble(hint)
846
+ // 实时预览(UI 未保存草稿):补丁挂进装配上下文,过滤器在 resolveTarget
847
+ // 里把它叠加到目标层 —— 只读内存,磁盘不动。
848
+ if (patch !== undefined) {
849
+ hint.promptCustomizerPatch = patch
850
+ hint.promptCustomizerTarget = patchTarget
814
851
  }
852
+ // 纯 scope 诊断装配(不带 agent,见上方函数注释):让宿主 roster 不变量
853
+ // 按「诊断」放行,三个阶段视图由 promptCustomizerPhase hint 驱动。
854
+ const assembled = await ctx.systemPrompt.assemble(hint)
815
855
  const sections = Array.isArray(assembled.sections)
816
856
  ? assembled.sections.map((section) => ({ name: section.name, text: String(section.text ?? '') }))
817
857
  : []
@@ -840,7 +880,6 @@ async function buildPreview(ctx, scopeKey, hintId, phase, catalog) {
840
880
  return {
841
881
  ok: true,
842
882
  scope: scopeKey,
843
- degraded,
844
883
  registryTotal,
845
884
  /** 该 scope 注册表的工具名清单:供界面区分「在本预设里、只是被该阶段裁掉」
846
885
  * 与「根本不属于本预设(无法加回)」。 */
package/lib/promotion.js CHANGED
@@ -1,80 +1,76 @@
1
1
  /**
2
- * Epoch-aware 晋级 tracker —— 行为对标 agent-presets 实验预设里的
3
- * compaction-epoch.mjs:晋级由 durable 会话事件推导(默认首个
4
- * `tool/call` 或 `assistant/message`),compaction 之后复位(首个压缩后
5
- * 请求回到 bootstrap 态,直到新的晋级信号),subagent 视为恒已晋级。
2
+ * Epoch-aware 晋级(bootstrap / active)阶段推导 + 会话预设解析。
6
3
  *
7
- * 状态按 session.id 记忆,冷会话扫一遍 durable log 后 O(1),因此 resume
8
- * / reload 重建出相同的阶段。
4
+ * 阶段语义(行为对标 agent-presets 实验预设里的 compaction-epoch.mjs):晋级由
5
+ * durable 会话事件推导(默认首个 `tool/call` 或 `assistant/message`),compaction
6
+ * 之后复位(首个压缩后请求回到 bootstrap 态,直到新的晋级信号),subagent 视为
7
+ * 恒已晋级。resume / reload 后由宿主投影的懒折叠从日志重建出相同的阶段。
8
+ *
9
+ * 读取方式:宿主 0.1.2-alpha.4 移除了 `Session.events`,0.1.6 又把同步历史读
10
+ * (`eventAt` / `snapshotEvents` / `ownEvents`)标记为 deprecated(见
11
+ * .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md,
12
+ * 长期方向是「投影状态 + 增量维护」)。因此本插件不再自行扫历史:阶段推导注册成
13
+ * 宿主的会话投影 unit(宿主负责折叠与增量驱动),预设解析直接读宿主 agent-presets
14
+ * 注册的 `agentPreset` 投影。投影服务缺席(自组 profile 未挂载 session-projection)
15
+ * 时,读取端回落到「已晋级」——与清单读取的静态视图一致,绝不阻断装配。
9
16
  */
10
17
 
18
+ import { z } from 'zod'
19
+
11
20
  /** 默认的晋级信号事件。 */
12
21
  const DEFAULT_PROMOTE_EVENTS = ['tool/call', 'assistant/message']
13
22
 
14
- /** 构建一个晋级 tracker;promoteEvents 可换成单事件数组。 */
15
- export function createPromotion(promoteEvents = DEFAULT_PROMOTE_EVENTS) {
16
- const promote = new Set(promoteEvents)
17
- /** sessionId -> { boundary, promoted } */
18
- const state = new Map()
23
+ /** 本插件注册的阶段投影 key(带命名空间,避免与宿主 / 其它插件撞名)。 */
24
+ export const PHASE_PROJECTION_KEY = 'prompt-customizer/phase'
19
25
 
20
- /** 从 durable log 冷扫描。 */
21
- const scan = (session) => {
22
- let boundary = -1
23
- let promoted = false
24
- for (const event of session.events ?? []) {
25
- const seq = event.seq ?? 0 // 没有 seq 的事件视为边界之后
26
- if (event.type === 'compaction/end') {
27
- boundary = seq
28
- promoted = false
29
- continue
30
- }
31
- if (promote.has(event.type) && seq > boundary) promoted = true
32
- }
33
- const entry = { boundary, promoted }
34
- state.set(session.id, entry)
35
- return entry
36
- }
26
+ /** 无从推导时的阶段:视为已晋级(静态视图)。 */
27
+ export const PROMOTED_STATUS = Object.freeze({ boundary: -1, promoted: true })
37
28
 
29
+ /**
30
+ * 构建阶段投影 unit(`ctx.sessionProjections.register` 的入参)。
31
+ *
32
+ * state 是纯 JSON `{ boundary, promoted }`(投影契约要求);apply 是纯函数,
33
+ * 未命中事件必须返回原引用(宿主以 Object.is 抑制下游工作)。
34
+ * ponytail: 只覆盖 compaction 边界这一种 epoch,未跟踪 fork 语义。
35
+ * 升级路径:参照 anchored-standard 的 fork-aware 边界扩展 apply。
36
+ */
37
+ export function createPhaseProjection(promoteEvents = DEFAULT_PROMOTE_EVENTS) {
38
+ const promote = new Set(promoteEvents)
38
39
  return {
39
- /**
40
- * 当前 agent 的阶段。agent 缺席(纯清单读取等)或子代理一律已晋级;
41
- * ponytail: 只覆盖 compaction 边界这一种 epoch,未跟踪 fork 语义。
42
- * 升级路径:参照 anchored-standard 的 fork-aware 边界扩展 scan。
43
- */
44
- status(agent) {
45
- const session = agent?.session
46
- if (!session) return { boundary: -1, promoted: true }
47
- if ((session.header?.delegationDepth ?? 0) > 0) return { boundary: -1, promoted: true }
48
- return state.get(session.id) ?? scan(session)
49
- },
50
-
51
- /** 增量喂入 `session/event`,避免每次装配全量扫描。 */
52
- observe(session, event) {
53
- const entry = state.get(session.id)
54
- if (entry === undefined) return
55
- const seq = event.seq ?? 0
56
- if (event.type === 'compaction/end') {
57
- state.set(session.id, { boundary: seq, promoted: false })
58
- return
59
- }
60
- if (promote.has(event.type) && seq > entry.boundary && !entry.promoted) {
61
- state.set(session.id, { ...entry, promoted: true })
40
+ key: PHASE_PROJECTION_KEY,
41
+ stateSchema: z.object({ boundary: z.number().int(), promoted: z.boolean() }),
42
+ init: () => ({ boundary: -1, promoted: false }),
43
+ apply: (state, event) => {
44
+ const seq = event.seq ?? 0 // 没有 seq 的事件视为边界之后
45
+ if (event.type === 'compaction/end') return { boundary: seq, promoted: false }
46
+ if (promote.has(event.type) && seq > state.boundary && !state.promoted) {
47
+ return { ...state, promoted: true }
62
48
  }
49
+ return state
63
50
  },
51
+ stateVersion: 1,
64
52
  }
65
53
  }
66
54
 
67
55
  /**
68
- * 解析一个会话所属的 agent 预设 id:header 记录创建时值,
69
- * `agent-preset/selected` 事件记录空白期切换后的值,后者优先。
56
+ * 当前 agent 的阶段。agent 缺席(纯清单读取等)或子代理一律已晋级;
57
+ * 投影 unit 未注册(服务缺席 / 尚未注册)时同样回落到已晋级。
70
58
  */
71
- export function presetOfSession(session) {
72
- if (!session) return undefined
73
- let preset = typeof session.header?.agentPreset === 'string' ? session.header.agentPreset : undefined
74
- for (const event of session.events ?? []) {
75
- if (event.type === 'agent-preset/selected' && typeof event.data?.agentPreset === 'string') {
76
- preset = event.data.agentPreset
77
- }
78
- }
79
- return preset
59
+ export function statusOf(projections, agent) {
60
+ const session = agent?.session
61
+ if (!session) return PROMOTED_STATUS
62
+ if ((session.header?.delegationDepth ?? 0) > 0) return PROMOTED_STATUS
63
+ return projections?.stateOf?.(session, PHASE_PROJECTION_KEY) ?? PROMOTED_STATUS
80
64
  }
65
+
66
+ /**
67
+ * 解析一个会话所属的 agent 预设 id:优先宿主 agent-presets 注册的 `agentPreset`
68
+ * 投影(header 记录创建时值,投影随 `agent-preset/selected` 事件推进),
69
+ * 投影缺席(无 roster 的组合)时回落到 header 的创建时值。
70
+ */
71
+ export function presetOfSession(projections, session) {
72
+ if (!session) return undefined
73
+ const projected = projections?.stateOf?.(session, 'agentPreset')
74
+ if (typeof projected === 'string') return projected
75
+ return typeof session.header?.agentPreset === 'string' ? session.header.agentPreset : undefined
76
+ }
@@ -30,3 +30,38 @@ export function phaseInjectEntries<T extends object>(
30
30
  key: PhaseKey,
31
31
  rows: T[],
32
32
  ): Array<Record<string, unknown>>
33
+ /** 三态同步持久化:rowsByKey 给出三个阶段各自的有序行集合,生成完整 inject
34
+ * 列表(always 条目原样保留,三阶段各自连续虚拟 order)。 */
35
+ export function mergedPhaseInjectEntries<T extends object>(
36
+ cfg: unknown,
37
+ rowsByKey: Record<PhaseKey, T[]>,
38
+ ): Array<Record<string, unknown>>
39
+ /** 提示词 Tab 一个阶段部分的行(含屏蔽行与注入行)。 */
40
+ export interface PhaseRow {
41
+ name: string
42
+ text: string
43
+ replaced: boolean
44
+ custom: boolean
45
+ override: string
46
+ blocked: boolean
47
+ }
48
+ /** 一个阶段部分的全部行(post ∪ 被屏蔽 ∪ 本阶段注入条目,草稿序叠加)。
49
+ * `view` = 三阶段预览装配里对应阶段的视图(可为 null = 视为空)。 */
50
+ export function phaseRows(cfg: unknown, view: unknown, key: PhaseKey): PhaseRow[]
51
+ /** 「应用中文」(开关开启)一次性覆盖:三阶段行里名字命中 zhMap 的非自定义
52
+ * 行覆盖为中文译本,生成完整 inject 列表;未命中保持原样。zhMap 条目为字符
53
+ * 串或函数(入参该行原文,返回空值 = 放弃替换)。 */
54
+ export function zhMergedInjectEntries(
55
+ cfg: unknown,
56
+ views: unknown,
57
+ zhMap: Record<string, string | ((original: string) => string | null | undefined)>,
58
+ ): Array<Record<string, unknown>>
59
+ /** 「回归英文」(开关关闭):命中译本的非自定义行清空替换文本(服务端回落
60
+ * 注册表英文原文),生成完整 inject 列表;自定义段不碰。 */
61
+ export function zhRevertInjectEntries(
62
+ cfg: unknown,
63
+ views: unknown,
64
+ zhMap: Record<string, string | ((original: string) => string | null | undefined)>,
65
+ ): Array<Record<string, unknown>>
66
+ /** 开关状态探测:任一注入条目文本等于字符串译本即视为已应用。 */
67
+ export function zhApplied(cfg: unknown, zhMap: Record<string, string | ((original: string) => string | null | undefined)>): boolean