@zhushanwen/pi-system-prompt 1.1.6 → 1.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-system-prompt",
3
- "version": "1.1.6",
3
+ "version": "1.3.0",
4
4
  "description": "System prompt injection extension for Pi — reads config and appends to system prompt",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -4,13 +4,19 @@
4
4
  * 覆盖(替换原 expect(true) 占位,R3 extension-api SUGGESTION #1):
5
5
  * - readJsonIfValid 解析边界:文件缺失 / 畸形 JSON / 顶层 array / 顶层原始值 → 全部收敛 defaults
6
6
  * - readSection 字段级防御:section 非对象、enabled 非 true、prompt 非字符串 / 空白 → 不注入
7
- * - before_agent_start 注入顺序:base → global instructions → append config(indexOf 链锁定,
7
+ * - capability 段三态锚点(设计 D6):v1 存量 json(无字段)→ 注入;显式布尔 false → 不注入;
8
+ * 损坏形态("enabled": "false" 字符串 / capability 非对象)→ 注入(与 readSection 解析方向相反)
9
+ * - before_agent_start 注入顺序:base → global instructions → capability → append(indexOf 链锁定,
8
10
  * swap 注入顺序两行的 mutant 会被顺序用例 kill)
9
11
  * - -nc / --no-context-files 守卫:global 注入跳过、append 不受影响
10
12
  * - global 候选选择:候选序优先、空白内容跳过继续找、目录缺失降级
11
13
  * - fail-safe:handler 全程 throw → return undefined + logger.error 可观测;logger 自身抛错的
12
14
  * 终极兜底 process.stderr.write;systemPrompt 非法类型的旧 quirk 锚定
13
15
  *
16
+ * 适配约定:capability 默认开(仅显式布尔 false 关闭)。聚焦 append/global 语义的既有用例
17
+ * 在 config 里显式 "capability":{"enabled":false} 隔离关注点;无法塞字段的用例(畸形 JSON /
18
+ * 文件缺失)期望值如实更新为含 capability 段。capability 自身行为由专项 describe 锚定。
19
+ *
14
20
  * mock 策略(参照 msg-id-mapper 测试模式):pi SDK import type 零运行时解析,
15
21
  * ExtensionAPI 用结构化桩;node:fs mock 后按路径分流(env 指向假目录,不碰真实文件系统)。
16
22
  *
@@ -27,7 +33,13 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
27
33
 
28
34
  import { readFileSync, readdirSync, statSync } from 'node:fs'
29
35
  import path from 'node:path'
30
- import createExtension from '../index'
36
+ import createExtension, {
37
+ CAPABILITY_FORBIDDEN,
38
+ CAPABILITY_INLINE_TAG_FAMILIES,
39
+ CAPABILITY_PRESENTATION_ATTRS,
40
+ SESSION_ARTIFACTS_DIR_SEGMENT,
41
+ resolveSessionArtifactsDir,
42
+ } from '../index'
31
43
  import type { ExtensionAPI, BeforeAgentStartEvent } from '@earendil-works/pi-coding-agent'
32
44
 
33
45
  vi.mock('node:fs', () => ({
@@ -40,8 +52,13 @@ const DATA_DIR = '/taiji-test/data'
40
52
  const GLOBAL_DIR = '/taiji-test/global-agents'
41
53
  const CONFIG_PATH = path.join(DATA_DIR, 'system-prompt.json')
42
54
 
43
- /** hook 注册表桩(参照 msg-id-mapper harness 模式) */
44
- function createHarness(): { beforeAgentStart: (event: BeforeAgentStartEvent) => unknown } {
55
+ /** capability 段 header 锚(文案变更须同步本锚,红 = 提醒评审段文本变化) */
56
+ const CAP_HEADER = '# TaiJi capabilities'
57
+ /** 既有用例隔离关注点用的「capability 显式关闭」config 片段 */
58
+ const CAP_OFF = '"capability": {"enabled": false}'
59
+
60
+ /** hook 注册表桩(参照 msg-id-mapper harness 模式)——handler 第二参 ctx 透传(路径注入测试用) */
61
+ function createHarness(): { beforeAgentStart: (event: BeforeAgentStartEvent, ctx?: unknown) => unknown } {
45
62
  const handlers = new Map<string, (...args: unknown[]) => unknown>()
46
63
  const pi = {
47
64
  on: (event: string, handler: (...args: unknown[]) => unknown) => {
@@ -50,14 +67,14 @@ function createHarness(): { beforeAgentStart: (event: BeforeAgentStartEvent) =>
50
67
  } as unknown as ExtensionAPI
51
68
  createExtension(pi)
52
69
  return {
53
- beforeAgentStart: (event) => handlers.get('before_agent_start')!(event),
70
+ beforeAgentStart: (event, ctx) => handlers.get('before_agent_start')!(event, ctx),
54
71
  }
55
72
  }
56
73
 
57
- /** 触发一次 hook 的便捷封装(常规 event:systemPrompt 字符串) */
58
- function runHook(systemPrompt: string): { systemPrompt?: string } | undefined {
74
+ /** 触发一次 hook 的便捷封装(常规 event:systemPrompt 字符串;ctx 可选) */
75
+ function runHook(systemPrompt: string, ctx?: unknown): { systemPrompt?: string } | undefined {
59
76
  const h = createHarness()
60
- return h.beforeAgentStart({ type: 'before_agent_start', prompt: 'hi', systemPrompt }) as
77
+ return h.beforeAgentStart({ type: 'before_agent_start', prompt: 'hi', systemPrompt }, ctx) as
61
78
  | { systemPrompt?: string }
62
79
  | undefined
63
80
  }
@@ -136,62 +153,64 @@ afterEach(() => {
136
153
  vi.restoreAllMocks()
137
154
  })
138
155
 
139
- describe('readJsonIfValid 解析边界(config 读取 → defaults 收敛)', () => {
140
- it('config 文件缺失(ENOENT)→ defaults → 无注入,返回 undefined', () => {
156
+ describe('readJsonIfValid 解析边界(config 读取 → defaults 收敛,capability 默认开)', () => {
157
+ // 本组 config 均无法塞字段(缺失/畸形/非对象),capability 走默认 true——
158
+ // defaults 的语义自 schema v2 起含「capability 段注入」,期望如实更新。
159
+ it('config 文件缺失(ENOENT)→ defaults → 仅注入 capability 段', () => {
141
160
  setupFs({ config: new Error("ENOENT: no such file or directory, open '" + CONFIG_PATH + "'") })
142
- expect(runHook('base prompt')).toBeUndefined()
161
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
143
162
  })
144
163
 
145
- it('config 畸形 JSON(parse throw)→ defaults → 无注入', () => {
164
+ it('config 畸形 JSON(parse throw)→ defaults → 仅注入 capability 段', () => {
146
165
  setupFs({ config: '{broken json' })
147
- expect(runHook('base prompt')).toBeUndefined()
166
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
148
167
  })
149
168
 
150
- it('config 顶层 array → isJsonObject 放行 quirk(数组不排除)→ 字段缺省收敛 defaults → 无注入', () => {
169
+ it('config 顶层 array → isJsonObject 放行 quirk(数组不排除)→ 字段缺省收敛 defaults → 仅 capability 段', () => {
151
170
  // R3 复核锚定的行为等价:顶层数组两版实现同走 typeof object 放行路径,无错误数据
152
171
  setupFs({ config: '[1, 2]' })
153
- expect(runHook('base prompt')).toBeUndefined()
172
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
154
173
  })
155
174
 
156
- it('config 顶层原始值(number / string / null 字面量)→ null → defaults → 无注入', () => {
175
+ it('config 顶层原始值(number / string / null 字面量)→ null → defaults → 仅 capability 段', () => {
157
176
  for (const bad of ['42', '"a string"', 'null', 'true']) {
158
177
  setupFs({ config: bad })
159
- expect(runHook('base prompt')).toBeUndefined()
178
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
160
179
  }
161
180
  })
162
181
  })
163
182
 
164
- describe('readSection 字段级防御(append section)', () => {
183
+ describe('readSection 字段级防御(append section;capability 显式关闭以隔离关注点)', () => {
165
184
  it('append section 非对象(null / 字符串)→ {enabled:false, prompt:""} → 不注入', () => {
166
185
  for (const section of ['null', '"just text"']) {
167
- setupFs({ config: `{"append": ${section}}` })
186
+ setupFs({ config: `{"append": ${section}, ${CAP_OFF}}` })
168
187
  expect(runHook('base prompt')).toBeUndefined()
169
188
  }
170
189
  })
171
190
 
172
191
  it('append.enabled 非 true(字符串 "true")→ 不视为开启 → 不注入', () => {
173
- setupFs({ config: '{"append": {"enabled": "true", "prompt": "extra"}}' })
192
+ setupFs({ config: `{"append": {"enabled": "true", "prompt": "extra"}, ${CAP_OFF}}` })
174
193
  expect(runHook('base prompt')).toBeUndefined()
175
194
  })
176
195
 
177
196
  it('append.prompt 非字符串(number)→ 缺省 "" → 不注入', () => {
178
- setupFs({ config: '{"append": {"enabled": true, "prompt": 123}}' })
197
+ setupFs({ config: `{"append": {"enabled": true, "prompt": 123}, ${CAP_OFF}}` })
179
198
  expect(runHook('base prompt')).toBeUndefined()
180
199
  })
181
200
 
182
201
  it('append.enabled true 但 prompt 纯空白 → trim 后为空 → 不注入', () => {
183
- setupFs({ config: '{"append": {"enabled": true, "prompt": " \\n\\t "}}' })
202
+ setupFs({ config: `{"append": {"enabled": true, "prompt": " \\n\\t "}, ${CAP_OFF}}` })
184
203
  expect(runHook('base prompt')).toBeUndefined()
185
204
  })
186
205
 
187
206
  it('append 合法(enabled true + 非空 prompt)→ 注入到 base 之后(\\n\\n 分隔)', () => {
188
- setupFs({ config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}' })
207
+ setupFs({ config: `{"append": {"enabled": true, "prompt": "APPEND-TEXT"}, ${CAP_OFF}}` })
189
208
  expect(runHook('base prompt')).toEqual({ systemPrompt: 'base prompt\n\nAPPEND-TEXT' })
190
209
  })
191
210
  })
192
211
 
193
- describe('before_agent_start 注入顺序(base → global → append)', () => {
194
- it('三段齐备 → base 在前、global 段居中、append 文本最后(indexOf 链锁定)', () => {
212
+ describe('before_agent_start 注入顺序(base → global → capability → append)', () => {
213
+ it('四段齐备 → base 最前、global 居中、capability 段次之、append 最后(indexOf 链锁定)', () => {
195
214
  setupFs({
196
215
  config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}',
197
216
  globalEntries: ['AGENTS.md'],
@@ -201,35 +220,106 @@ describe('before_agent_start 注入顺序(base → global → append)', () =
201
220
  expect(result).toEqual({ systemPrompt: expect.stringContaining('APPEND-TEXT') })
202
221
  const prompt = result!.systemPrompt as string
203
222
 
204
- // 顺序锚点:base 最前 → global header → global 内容 → append 文本最后
223
+ // 顺序锚点:base → global header → global 内容 → capability 段 → append 文本
205
224
  const iBase = prompt.indexOf('BASE-PROMPT')
206
225
  const iHeader = prompt.indexOf('# Global instructions')
207
226
  const iGlobal = prompt.indexOf('GLOBAL-CONTENT')
227
+ const iCap = prompt.indexOf(CAP_HEADER)
208
228
  const iAppend = prompt.indexOf('APPEND-TEXT')
209
229
  expect(iBase).toBeGreaterThanOrEqual(0)
210
230
  expect(iHeader).toBeGreaterThan(iBase)
211
231
  expect(iGlobal).toBeGreaterThan(iHeader)
212
- expect(iAppend).toBeGreaterThan(iGlobal)
232
+ expect(iCap).toBeGreaterThan(iGlobal)
233
+ expect(iAppend).toBeGreaterThan(iCap)
213
234
  expect(prompt.indexOf('APPEND-TEXT', iAppend + 1)).toBe(-1) // append 恰一次
235
+ expect(prompt.indexOf(CAP_HEADER, iCap + 1)).toBe(-1) // capability 段恰一次
214
236
  // global header 带真实注入路径(可追溯)
215
237
  expect(prompt).toContain(path.join(GLOBAL_DIR, 'AGENTS.md'))
216
238
  })
217
239
 
218
- it('global 无候选文件 → 只剩 base + append 两段', () => {
240
+ it('capability 段文案覆盖四点能力面(HTML 白名单 / 相对图片 / 相对链接双通道 / 远程图片不渲染)', () => {
241
+ setupFs({ config: '{"append": {"enabled": false, "prompt": ""}}' })
242
+ const prompt = runHook('BASE-PROMPT')!.systemPrompt as string
243
+ const capText = prompt.slice(prompt.indexOf(CAP_HEADER))
244
+ expect(capText).toContain('Inline HTML')
245
+ expect(capText).toContain('Relative image paths')
246
+ expect(capText).toContain('Relative links')
247
+ expect(capText).toContain('not rendered')
248
+ expect(capText).toContain('backticks')
249
+ })
250
+
251
+ it('capability 关闭(显式布尔 false)→ 段消失,其余段顺序不变', () => {
252
+ setupFs({
253
+ config: `{"append": {"enabled": true, "prompt": "APPEND-TEXT"}, ${CAP_OFF}}`,
254
+ globalEntries: ['AGENTS.md'],
255
+ globalFiles: { 'AGENTS.md': 'GLOBAL-CONTENT' },
256
+ })
257
+ const prompt = runHook('BASE-PROMPT')!.systemPrompt as string
258
+ expect(prompt).not.toContain(CAP_HEADER)
259
+ // 三段顺序保持 base → global → append(capability 摘除不动既有链路)
260
+ const iBase = prompt.indexOf('BASE-PROMPT')
261
+ const iGlobal = prompt.indexOf('GLOBAL-CONTENT')
262
+ const iAppend = prompt.indexOf('APPEND-TEXT')
263
+ expect(iBase).toBeGreaterThanOrEqual(0)
264
+ expect(iGlobal).toBeGreaterThan(iBase)
265
+ expect(iAppend).toBeGreaterThan(iGlobal)
266
+ })
267
+
268
+ it('global 无候选文件 → 只剩 base + capability + append 三段', () => {
219
269
  setupFs({
220
270
  config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}',
221
271
  globalEntries: [],
222
272
  })
223
273
  const result = runHook('BASE-PROMPT')
224
- expect(result).toEqual({ systemPrompt: 'BASE-PROMPT\n\nAPPEND-TEXT' })
225
- expect(result!.systemPrompt).not.toContain('# Global instructions')
274
+ const prompt = result!.systemPrompt as string
275
+ expect(prompt).not.toContain('# Global instructions')
276
+ expect(prompt).toContain(CAP_HEADER)
277
+ expect(prompt).toContain('APPEND-TEXT')
278
+ expect(prompt.indexOf(CAP_HEADER)).toBeGreaterThan(prompt.indexOf('BASE-PROMPT'))
279
+ expect(prompt.indexOf('APPEND-TEXT')).toBeGreaterThan(prompt.indexOf(CAP_HEADER))
280
+ })
281
+ })
282
+
283
+ describe('capability 段三态锚点(设计 D6:仅显式布尔 false 关闭,与 readSection 方向相反)', () => {
284
+ it('v1 存量 json(无 capability 字段)→ 默认开 → 注入', () => {
285
+ setupFs({ config: '{"version": 1, "replace": {"enabled": false, "prompt": ""}, "append": {"enabled": false, "prompt": ""}}' })
286
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
287
+ })
288
+
289
+ it('config 文件不存在 → 同 v1 语义 → 注入', () => {
290
+ setupFs()
291
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
292
+ })
293
+
294
+ it('显式 enabled: false(布尔)→ 关闭 → 不注入', () => {
295
+ setupFs({ config: `{"append": {"enabled": false, "prompt": ""}, ${CAP_OFF}}` })
296
+ expect(runHook('base prompt')).toBeUndefined()
297
+ })
298
+
299
+ it('显式 enabled: true → 注入', () => {
300
+ setupFs({ config: '{"append": {"enabled": false, "prompt": ""}, "capability": {"enabled": true}}' })
301
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
302
+ })
303
+
304
+ it('损坏形态:enabled 为字符串 "false"(非布尔)→ 不视为关闭 → 注入', () => {
305
+ setupFs({ config: '{"append": {"enabled": false, "prompt": ""}, "capability": {"enabled": "false"}}' })
306
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
307
+ })
308
+
309
+ it('损坏形态:capability 字段非对象(字符串 / null / 数组)→ 注入', () => {
310
+ for (const bad of ['"off"', 'null', '[1]']) {
311
+ setupFs({ config: `{"append": {"enabled": false, "prompt": ""}, "capability": ${bad}}` })
312
+ expect(runHook('base prompt')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
313
+ }
226
314
  })
227
315
  })
228
316
 
229
317
  describe('-nc / --no-context-files 守卫(contextFilesDisabled)', () => {
318
+ // capability 与守卫正交(内置段不受 --no-context-files 影响,该旗标只管上下文文件
319
+ // 发现),用例显式关 capability 隔离守卫语义。
230
320
  it('argv 含 --no-context-files → global 不注入,append 仍生效(用户显式退出不得溜回来)', () => {
231
321
  setupFs({
232
- config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}',
322
+ config: `{"append": {"enabled": true, "prompt": "APPEND-TEXT"}, ${CAP_OFF}}`,
233
323
  globalEntries: ['AGENTS.md'],
234
324
  globalFiles: { 'AGENTS.md': 'GLOBAL-CONTENT' },
235
325
  })
@@ -239,9 +329,9 @@ describe('-nc / --no-context-files 守卫(contextFilesDisabled)', () => {
239
329
  expect(readdirSync).not.toHaveBeenCalled()
240
330
  })
241
331
 
242
- it('argv 含 -nc 短形式 → 同样跳过 global 注入(与 argv-mirror 两种形式一致)', () => {
332
+ it('argv 含 -nc 短形式 → 同样跳过 global 注入(pi CLI 等价短形式,守卫两种形式都命中)', () => {
243
333
  setupFs({
244
- config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}',
334
+ config: `{"append": {"enabled": true, "prompt": "APPEND-TEXT"}, ${CAP_OFF}}`,
245
335
  globalEntries: ['AGENTS.md'],
246
336
  globalFiles: { 'AGENTS.md': 'GLOBAL-CONTENT' },
247
337
  })
@@ -251,30 +341,32 @@ describe('-nc / --no-context-files 守卫(contextFilesDisabled)', () => {
251
341
  })
252
342
 
253
343
  describe('global 候选文件选择(readGlobalAgentsFile)', () => {
254
- it('候选序优先:AGENTS.MD 与 CLAUDE.md 并存 → AGENTS.MD 胜(候选列表顺序的第一个存在者)', () => {
344
+ it('候选序优先:AGENTS.md 与 AGENTS.MD 并存 → AGENTS.md 胜(候选列表顺序的第一个存在者)', () => {
255
345
  setupFs({
256
- globalEntries: ['AGENTS.MD', 'CLAUDE.md'],
257
- globalFiles: { 'AGENTS.MD': 'FROM-AGENTS-UPPER', 'CLAUDE.md': 'FROM-CLAUDE' },
346
+ globalEntries: ['AGENTS.md', 'AGENTS.MD'],
347
+ globalFiles: { 'AGENTS.md': 'FROM-LOWER', 'AGENTS.MD': 'FROM-UPPER' },
258
348
  })
259
349
  const result = runHook('BASE-PROMPT')
260
- expect(result!.systemPrompt).toContain('FROM-AGENTS-UPPER')
261
- expect(result!.systemPrompt).not.toContain('FROM-CLAUDE')
262
- expect(result!.systemPrompt).toContain(path.join(GLOBAL_DIR, 'AGENTS.MD'))
350
+ expect(result!.systemPrompt).toContain('FROM-LOWER')
351
+ expect(result!.systemPrompt).not.toContain('FROM-UPPER')
352
+ expect(result!.systemPrompt).toContain(path.join(GLOBAL_DIR, 'AGENTS.md'))
263
353
  })
264
354
 
265
- it('首候选内容空白 → 跳过继续找下一候选(AGENTS.md 空白 + CLAUDE.md 有内容 → 注入 CLAUDE.md)', () => {
355
+ it('首候选内容空白 → 跳过继续找下一候选(AGENTS.md 空白 + AGENTS.MD 有内容 → 注入 AGENTS.MD)', () => {
266
356
  setupFs({
267
- globalEntries: ['AGENTS.md', 'CLAUDE.md'],
268
- globalFiles: { 'AGENTS.md': ' \n\t', 'CLAUDE.md': 'FROM-CLAUDE' },
357
+ globalEntries: ['AGENTS.md', 'AGENTS.MD'],
358
+ globalFiles: { 'AGENTS.md': ' \n\t', 'AGENTS.MD': 'FROM-UPPER' },
269
359
  })
270
360
  const result = runHook('BASE-PROMPT')
271
- expect(result!.systemPrompt).toContain('FROM-CLAUDE')
272
- expect(result!.systemPrompt).toContain(path.join(GLOBAL_DIR, 'CLAUDE.md'))
361
+ expect(result!.systemPrompt).toContain('FROM-UPPER')
362
+ expect(result!.systemPrompt).toContain(path.join(GLOBAL_DIR, 'AGENTS.MD'))
273
363
  })
274
364
 
275
- it('global 目录不存在(readdirSync throw)→ 降级 null:不注入、不抛错', () => {
365
+ it('global 目录不存在(readdirSync throw)→ 降级 null:global 不注入、不抛错(capability 默认段仍在)', () => {
276
366
  setupFs({ globalEntries: new Error('ENOENT: no such directory') })
277
- expect(runHook('BASE-PROMPT')).toBeUndefined()
367
+ const result = runHook('BASE-PROMPT')
368
+ expect(result).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
369
+ expect(result!.systemPrompt).not.toContain('# Global instructions')
278
370
  })
279
371
  })
280
372
 
@@ -311,20 +403,26 @@ describe('fail-safe(外层 catch return undefined,永不阻断 agent loop)
311
403
  )
312
404
  })
313
405
 
314
- it('systemPrompt 非法类型(undefined)且无注入 → 旧 quirk 锚定:返回 {systemPrompt:""} 而非 undefined', () => {
406
+ it('systemPrompt 非法类型(undefined)且无 append → 旧 quirk 锚定:返回仅含 capability 段的结果而非 undefined', () => {
315
407
  // R3 复核锚定的返回值守卫 quirk(index.ts newPrompt === event.systemPrompt 比较):
316
- // base 收敛 '','' !== undefined → 返回 {systemPrompt: ''}。与重构前行为一致(非回归)。
408
+ // base 收敛 '',capability 默认开 → newPrompt 非空且 !== undefined → 必然返回对象。
409
+ // 与重构前行为一致(非回归):quirk 在「有注入时必然返回对象」,schema v2 后 capability
410
+ // 默认开使该形态成为常态。
317
411
  const h = createHarness()
318
412
  const event = { type: 'before_agent_start', prompt: 'hi' } as unknown as BeforeAgentStartEvent
319
- expect(h.beforeAgentStart(event)).toEqual({ systemPrompt: '' })
413
+ expect(h.beforeAgentStart(event)).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
320
414
  })
321
415
 
322
- it('systemPrompt 非法类型(undefined)但有 append 注入 → newPrompt = 空串 base + 分隔符 + append', () => {
416
+ it('systemPrompt 非法类型(undefined)但有 append 注入 → 空串 base + capability 段 + append 依序拼接', () => {
323
417
  setupFs({ config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}' })
324
418
  const h = createHarness()
325
419
  const event = { type: 'before_agent_start', prompt: 'hi' } as unknown as BeforeAgentStartEvent
326
- // base 收敛 '',拼接形态固定为 '' + '\n\n' + append(分隔符保留,与合法 base 一致)
327
- expect(h.beforeAgentStart(event)).toEqual({ systemPrompt: '\n\nAPPEND-TEXT' })
420
+ // base 收敛 '',拼接形态固定为 '' + '\n\n' + capability + '\n\n' + append(分隔符保留,与合法 base 一致)
421
+ const result = h.beforeAgentStart(event) as { systemPrompt: string }
422
+ expect(result.systemPrompt).toContain(CAP_HEADER)
423
+ expect(result.systemPrompt).toContain('APPEND-TEXT')
424
+ expect(result.systemPrompt.indexOf('APPEND-TEXT')).toBeGreaterThan(result.systemPrompt.indexOf(CAP_HEADER))
425
+ expect(result.systemPrompt.startsWith('\n\n')).toBe(true)
328
426
  })
329
427
  })
330
428
 
@@ -343,17 +441,187 @@ describe('cachedReadFileSync(mtime 级内容缓存,KV-cache 稳定性改造
343
441
  })
344
442
 
345
443
  it('文件改写(mtime 变)后下一轮 hook 读到新内容(变更即生效语义保留)', () => {
346
- setupFs({ config: '{"append": {"enabled": true, "prompt": "P1"}}' })
444
+ // CAP_OFF:本条锚精确串「base + P1」,capability 开启会让期望值带上整段常量文案,
445
+ // 显式关闭以保持断言可读;capability 的变更即生效由 readConfig 共用路径保证。
446
+ setupFs({ config: `{"append": {"enabled": true, "prompt": "P1"}, ${CAP_OFF}}` })
347
447
  expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP1' })
348
448
  // 模拟用户改写 append.prompt(setupFs 递增 mtime 纪元)
349
- setupFs({ config: '{"append": {"enabled": true, "prompt": "P2"}}' })
449
+ setupFs({ config: `{"append": {"enabled": true, "prompt": "P2"}, ${CAP_OFF}}` })
350
450
  expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP2' })
351
451
  })
352
452
 
353
- it('文件删除(stat throw)后缓存驱逐,注入降级为无 append', () => {
354
- setupFs({ config: '{"append": {"enabled": true, "prompt": "P1"}}' })
453
+ it('文件删除(stat throw)后缓存驱逐,append 注入降级消失,capability 回默认开', () => {
454
+ setupFs({ config: `{"append": {"enabled": true, "prompt": "P1"}, ${CAP_OFF}}` })
355
455
  expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP1' })
356
- setupFs() // config 恢复默认 ENOENT
357
- expect(runHook('base')).toBeUndefined()
456
+ setupFs() // config 恢复默认 ENOENT → defaults:append 无、capability 默认开
457
+ expect(runHook('base')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
458
+ })
459
+ })
460
+
461
+ // ── 能力段 ①② 渲染锁定 + 产物目录镜像常量(chat-html-support u1-prompt)──────────
462
+
463
+ /** 能力段清单区的边界标签(文案与源码 renderCapabilitySection 渲染的 capability 段同字面量)。 */
464
+ const POS_TAGS_LABEL = 'You may use these tags: '
465
+ const POS_ATTRS_LABEL = 'Presentational attributes allowed: '
466
+ const FORBIDDEN_TAGS_LABEL = 'Do not use these tags (they are stripped before rendering): '
467
+ const FORBIDDEN_ATTRS_LABEL = 'Do not use these attributes (they are stripped and have no effect): '
468
+
469
+ /** 从能力段截取 startLabel..endLabel 之间的逗号分隔清单。缺失边界 → 断言失败(防静默跳过)。 */
470
+ function parseListRegion(section: string, startLabel: string, endLabel: string): string[] {
471
+ const i = section.indexOf(startLabel)
472
+ expect(i, `能力段缺少清单标签: ${startLabel}`).toBeGreaterThanOrEqual(0)
473
+ const start = i + startLabel.length
474
+ const j = section.indexOf(endLabel, start)
475
+ expect(j, `能力段缺少清单结束标签: ${endLabel}`).toBeGreaterThan(start)
476
+ return section.slice(start, j).split(', ').map((s) => s.trim())
477
+ }
478
+
479
+ /** 集合比较用:去重 + 稳定排序(渲染顺序不参与断言,成员集合才是契约)。 */
480
+ const asSet = (xs: readonly string[]): string[] => [...new Set(xs)].sort()
481
+
482
+ /** 触发一次 hook 并返回 capability 段全文(默认开关开、无 append / global)。 */
483
+ function capabilitySectionText(): string {
484
+ setupFs({ config: `{"append": {"enabled": false, "prompt": ""}, "capability": {"enabled": true}}` })
485
+ const prompt = runHook('BASE-PROMPT')!.systemPrompt as string
486
+ const i = prompt.indexOf(CAP_HEADER)
487
+ expect(i).toBeGreaterThanOrEqual(0)
488
+ return prompt.slice(i)
489
+ }
490
+
491
+ describe('capability 段 ①② 渲染锁定(文案 = 常量渲染产物,手写散文旁路即红)', () => {
492
+ it('正面清单逐项等于常量集合(含全部成员、无旁路成员、无重复)', () => {
493
+ const section = capabilitySectionText()
494
+ const tags = parseListRegion(section, POS_TAGS_LABEL, '. ' + POS_ATTRS_LABEL)
495
+ const attrs = parseListRegion(section, POS_ATTRS_LABEL, '.\n')
496
+ const allTags = Object.values(CAPABILITY_INLINE_TAG_FAMILIES).flat()
497
+
498
+ // 无重复(渲染旁路/手写追加会引入重复项)
499
+ expect(tags.length).toBe(new Set(tags).size)
500
+ expect(attrs.length).toBe(new Set(attrs).size)
501
+ // 集合逐项相等:缺成员 = 文案旁路漏写;多成员 = 手写散文多出
502
+ expect(asSet(tags)).toEqual(asSet(allTags))
503
+ expect(asSet(attrs)).toEqual(asSet(CAPABILITY_PRESENTATION_ATTRS))
504
+ })
505
+
506
+ it('负面清单逐项等于常量集合(构造性剥除的标签与属性)', () => {
507
+ const section = capabilitySectionText()
508
+ const forbiddenTags = parseListRegion(section, FORBIDDEN_TAGS_LABEL, '. ' + FORBIDDEN_ATTRS_LABEL)
509
+ const forbiddenAttrs = parseListRegion(section, FORBIDDEN_ATTRS_LABEL, '.\n')
510
+
511
+ expect(asSet(forbiddenTags)).toEqual(asSet(CAPABILITY_FORBIDDEN.tags))
512
+ expect(asSet(forbiddenAttrs)).toEqual(asSet(CAPABILITY_FORBIDDEN.attributes))
513
+ })
514
+
515
+ it('capability.enabled=false → 整段(含 ①② 清单)不注入', () => {
516
+ setupFs({ config: `{"append": {"enabled": false, "prompt": ""}, ${CAP_OFF}}` })
517
+ expect(runHook('BASE-PROMPT')).toBeUndefined()
518
+ })
519
+
520
+ it('清单常量成员与渲染管线白名单同域(包内自锚:52 标签 / 76 属性)', () => {
521
+ expect(Object.values(CAPABILITY_INLINE_TAG_FAMILIES).flat()).toHaveLength(52)
522
+ expect(CAPABILITY_PRESENTATION_ATTRS).toHaveLength(76)
523
+ // 族归类不重复:每个标签恰出现一次
524
+ const all = Object.values(CAPABILITY_INLINE_TAG_FAMILIES).flat()
525
+ expect(all.length).toBe(new Set(all).size)
526
+ // 禁用清单与正面清单不相交(构造性剥除语义)
527
+ const positive = new Set(all)
528
+ for (const tag of CAPABILITY_FORBIDDEN.tags) expect(positive.has(tag)).toBe(false)
529
+ })
530
+ })
531
+
532
+ describe('产物目录镜像常量与推导 helper(设计 D1/D7,供 u-artifacts 公式对拍消费)', () => {
533
+ it('段名字面量为 artifacts', () => {
534
+ expect(SESSION_ARTIFACTS_DIR_SEGMENT).toBe('artifacts')
535
+ })
536
+
537
+ it('合法 pi sid → <dataDir>/artifacts/<sessionId>', () => {
538
+ expect(resolveSessionArtifactsDir('0198ab12-cdef-7000-8000-1234567890ab')).toBe(
539
+ path.join(DATA_DIR, 'artifacts', '0198ab12-cdef-7000-8000-1234567890ab'),
540
+ )
541
+ // 允许 `.` 与 `_`(与 isPiSessionId 同域)
542
+ expect(resolveSessionArtifactsDir('a.b-c_d')).toBe(path.join(DATA_DIR, 'artifacts', 'a.b-c_d'))
543
+ })
544
+
545
+ it('非法 sessionId(含冒号 / 空串 / 首尾非字母数字)→ throw(防路径穿越)', () => {
546
+ for (const bad of ['btw:x', '', '_leading', 'trailing-', 'a/b', '..', 'a b']) {
547
+ expect(() => resolveSessionArtifactsDir(bad)).toThrow(/invalid sessionId/)
548
+ }
549
+ })
550
+
551
+ it('resolveDataDir 回退(无 TAIJI_AGENT_DATA_DIR 时取 PI_CODING_AGENT_DIR 上级)', () => {
552
+ delete process.env.TAIJI_AGENT_DATA_DIR
553
+ process.env.PI_CODING_AGENT_DIR = path.join('/tmp/fallback-data', 'agent')
554
+ expect(resolveSessionArtifactsDir('abc')).toBe(path.join('/tmp/fallback-data', 'artifacts', 'abc'))
555
+ })
556
+ })
557
+
558
+ // ── 能力段 ③④ 与产物目录路径注入(chat-html-support §6.1 D1 / §5.1 样例)────────────
559
+
560
+ /** 触发一次 hook 并返回 capability 段全文(capability 开、无 append / global;ctx 可选)。 */
561
+ function capabilityTextWithCtx(ctx?: unknown): string {
562
+ setupFs({ config: `{"append": {"enabled": false, "prompt": ""}, "capability": {"enabled": true}}` })
563
+ const prompt = runHook('BASE-PROMPT', ctx)!.systemPrompt as string
564
+ const i = prompt.indexOf(CAP_HEADER)
565
+ expect(i).toBeGreaterThanOrEqual(0)
566
+ return prompt.slice(i)
567
+ }
568
+
569
+ /** ctx 桩:sessionManager.getSessionId 返回给定值(u-foundation 核实的 pi ExtensionContext 形状) */
570
+ function ctxWithSessionId(getSessionId: () => string): unknown {
571
+ return { sessionManager: { getSessionId } }
572
+ }
573
+
574
+ describe('capability 段 ③④(HTML 交付约定 + 预览约束)与产物目录路径注入', () => {
575
+ const SID = '0198ab12-cdef-7000-8000-1234567890ab'
576
+
577
+ it('ctx 会话 id 在 → ③ 段含产物目录绝对路径(与会话 id 匹配)', () => {
578
+ const text = capabilityTextWithCtx(ctxWithSessionId(() => SID))
579
+ // 路径 = <dataDir>/artifacts/<sid>(与 shared getSessionArtifactsDir 同公式)
580
+ expect(text).toContain(resolveSessionArtifactsDir(SID))
581
+ expect(text).toContain(path.join(DATA_DIR, 'artifacts', SID))
582
+ // ③ 段交付约定与 ④ 段预览约束齐备
583
+ expect(text).toContain('info string is html-preview')
584
+ expect(text).toContain("that file's absolute path")
585
+ expect(text).toContain('update the file in place on later changes')
586
+ expect(text).toContain('recycled automatically once stale')
587
+ expect(text).toContain('Previewed HTML runs sandboxed with no network access')
588
+ expect(text).toContain('CDN links will not load')
589
+ })
590
+
591
+ it('会话 id 缺失(无 ctx / getSessionId 空串 / sessionManager 缺失)→ 该处退化且其余文案完整', () => {
592
+ const cases: (unknown | undefined)[] = [
593
+ undefined,
594
+ ctxWithSessionId(() => ''),
595
+ {},
596
+ ]
597
+ for (const ctx of cases) {
598
+ const text = capabilityTextWithCtx(ctx)
599
+ expect(text).toContain('session artifacts directory (unavailable this turn)')
600
+ // 其余 ③④ 文案完整(只路径处退化)
601
+ expect(text).toContain('info string is html-preview')
602
+ expect(text).toContain('regenerate on reference failure instead of assuming persistence')
603
+ expect(text).toContain('scripts/styles/images may be inline or reference local files')
604
+ expect(text).toContain('fonts must be inlined as data: URIs')
605
+ expect(text).toContain('blocked by the browser CORS policy')
606
+ expect(text).toContain('CDN links will not load')
607
+ // 不出现半截/伪造路径
608
+ expect(text).not.toContain(path.join(DATA_DIR, 'artifacts'))
609
+ }
610
+ })
611
+
612
+ it('getSessionId 抛错 / 非法 id(含冒号 virtual id)→ 退化不阻塞,其余文案完整', () => {
613
+ const throwing = ctxWithSessionId(() => { throw new Error('no session context') })
614
+ const virtualId = ctxWithSessionId(() => 'btw:0198ab12-cdef')
615
+ for (const ctx of [throwing, virtualId]) {
616
+ const text = capabilityTextWithCtx(ctx)
617
+ expect(text).toContain('(unavailable this turn)')
618
+ expect(text).toContain('info string is html-preview')
619
+ expect(text).not.toContain(path.join(DATA_DIR, 'artifacts'))
620
+ }
621
+ })
622
+
623
+ it('capability.enabled=false → ③④ 同整段一起不注入', () => {
624
+ setupFs({ config: `{"append": {"enabled": false, "prompt": ""}, ${CAP_OFF}}` })
625
+ expect(runHook('BASE-PROMPT', ctxWithSessionId(() => SID))).toBeUndefined()
358
626
  })
359
627
  })
package/src/index.ts CHANGED
@@ -4,10 +4,14 @@
4
4
  * Registers a `before_agent_start` hook that:
5
5
  * 1. Reads <dataDir>/system-prompt.json every turn (mtime-cached, see
6
6
  * `cachedReadFileSync`).
7
- * 2. When `append.enabled === true` and `append.prompt` is non-blank,
7
+ * 2. Appends the fixed taiji capability section unless explicitly disabled
8
+ * (`capability.enabled === false` — anything else, including a missing
9
+ * field, keeps it ON; deliberate inversion of the readSection fail-safe
10
+ * direction, see `readCapabilityEnabled`).
11
+ * 3. When `append.enabled === true` and `append.prompt` is non-blank,
8
12
  * appends the user's text to the event's systemPrompt.
9
- * 3. Reads the global instructions file `~/.agents/AGENTS.md` (candidates
10
- * AGENTS.md / AGENTS.MD / CLAUDE.md / CLAUDE.MD) every turn and appends it
13
+ * 4. Reads the global instructions file `~/.agents/AGENTS.md` (candidates
14
+ * AGENTS.md / AGENTS.MD, exact case match) every turn and appends it
11
15
  * under a labeled header. Modeled on pi's native `loadContextFileFromDir`
12
16
  * but deliberately narrower: pi 0.84.4 also probes `AGENTS.override.md`
13
17
  * and applies its candidate list to project dirs, whereas this list only
@@ -17,8 +21,9 @@
17
21
  * context-file opt-out). `TAIJI_GLOBAL_AGENTS_DIR` overrides the global
18
22
  * directory (test hook / escape hatch).
19
23
  *
20
- * Injection order per turn: base prompt → global instructions → append config
21
- * (the explicitly configured text wins last).
24
+ * Injection order per turn: base prompt → global instructions → taiji
25
+ * capability section → append config (the explicitly configured text wins
26
+ * last).
22
27
  *
23
28
  * Fail-safe: any error in the handler is swallowed and `undefined` is returned
24
29
  * so the agent loop is never blocked.
@@ -27,7 +32,7 @@
27
32
  import path from 'node:path'
28
33
  import { homedir } from 'node:os'
29
34
  import { readFileSync, readdirSync, statSync } from 'node:fs'
30
- import type { ExtensionAPI, BeforeAgentStartEvent } from '@earendil-works/pi-coding-agent'
35
+ import type { ExtensionAPI, BeforeAgentStartEvent, ExtensionContext } from '@earendil-works/pi-coding-agent'
31
36
  import { getLogger } from '@zhushanwen/pi-extension-logger'
32
37
 
33
38
  const logger = getLogger('taiji-system-prompt-extension')
@@ -61,12 +66,122 @@ function cachedReadFileSync(filePath: string): string | null {
61
66
 
62
67
  /**
63
68
  * Global instruction candidates. Modeled on pi's native loadContextFileFromDir
64
- * but deliberately not a strict mirror: pi 0.84.4 probes
65
- * ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]
66
- * against project dirs, while these candidates apply only to the global agents
67
- * directory and intentionally exclude AGENTS.override.md.
69
+ * but deliberately not a strict mirror: pi 0.84.4 probes its own candidate
70
+ * list (including AGENTS.override.md) against project dirs, while these
71
+ * candidates apply only to the global agents directory, intentionally exclude
72
+ * AGENTS.override.md, and recognize only AGENTS.md case variants.
68
73
  */
69
- const GLOBAL_AGENTS_CANDIDATES = ['AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
74
+ const GLOBAL_AGENTS_CANDIDATES = ['AGENTS.md', 'AGENTS.MD']
75
+
76
+ /**
77
+ * 对话流渲染管线的 HTML 能力边界(chat-html-support 设计 D1 ①②):机器可读的结构化
78
+ * 常量,能力段文案由这些常量渲染——禁止把清单写成散文旁路(成员集合可被
79
+ * `scripts/check-capability-allowlist-sync.mjs` 与渲染管线白名单对拍,零散文解析)。
80
+ *
81
+ * 成员来源 = `packages/renderer/src/composables/logic/markdown-sanitize.ts` 的
82
+ * `ALLOWED_TAGS` 实际字面量(52 项,GitHub 风格白名单翻译,按族归类,每项恰一次)。
83
+ * 与渲染白名单的任一方向漂移(清单多出未放行成员 / 白名单新增未入清单)由对拍机检
84
+ * 红灯拦住,pre-commit 按路径触发。
85
+ */
86
+ export const CAPABILITY_INLINE_TAG_FAMILIES: Readonly<Record<string, readonly string[]>> = {
87
+ headings: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
88
+ text: ['p', 'br', 'span', 'div', 'section', 'blockquote', 'pre', 'hr'],
89
+ inline: [
90
+ 'a', 'b', 'i', 'em', 'strong', 's', 'strike', 'del', 'ins',
91
+ 'code', 'kbd', 'samp', 'tt', 'var', 'sub', 'sup', 'q',
92
+ ],
93
+ lists: ['ul', 'ol', 'li', 'dl', 'dt', 'dd'],
94
+ table: ['table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'],
95
+ media: ['img', 'picture', 'source'],
96
+ annotation: ['ruby', 'rt', 'rp'],
97
+ details: ['details', 'summary'],
98
+ }
99
+
100
+ /**
101
+ * 可用呈现属性(设计 D1 ①):成员 = `markdown-sanitize.ts` 的 `ALLOWED_ATTR` 实际
102
+ * 字面量(defaultSchema 全局 '*' 表 + 按标签项;class 与任意 data-* 不翻译)。
103
+ */
104
+ export const CAPABILITY_PRESENTATION_ATTRS: readonly string[] = [
105
+ // defaultSchema 全局 '*' 表
106
+ 'abbr', 'accept', 'accept-charset', 'accesskey', 'action', 'align', 'alt', 'axis',
107
+ 'border', 'cellpadding', 'cellspacing', 'char', 'charoff', 'charset', 'checked',
108
+ 'clear', 'colspan', 'color', 'cols', 'compact', 'coords', 'datetime', 'dir',
109
+ 'disabled', 'enctype', 'frame', 'hspace', 'headers', 'height', 'hreflang', 'for',
110
+ 'id', 'ismap', 'itemprop', 'label', 'lang', 'maxlength', 'media', 'method',
111
+ 'multiple', 'name', 'nohref', 'noshade', 'nowrap', 'open', 'prompt', 'readonly',
112
+ 'rel', 'rev', 'rowspan', 'rows', 'rules', 'scope', 'selected', 'shape', 'size',
113
+ 'span', 'start', 'summary', 'tabindex', 'target', 'title', 'usemap', 'valign',
114
+ 'value', 'width',
115
+ // defaultSchema 按标签项
116
+ 'cite', 'itemscope', 'itemtype', 'longdesc', 'src', 'srcset', 'href',
117
+ 'aria-describedby', 'aria-label', 'aria-labelledby',
118
+ ]
119
+
120
+ /**
121
+ * 禁用清单(设计 D1 ②):净化层构造性剥除的标签与属性。
122
+ * - 标签 = 不在 `ALLOWED_TAGS` 的 script / style / link / iframe / form / input /
123
+ * button / svg;
124
+ * - 属性 = class / style 与通配 data-* / on*(净化层 `ALLOW_DATA_ATTR: false` 构造性
125
+ * 全剥 data-*,on* 事件处理器同理不存活)。
126
+ * 通配项以 `*` 结尾,对拍机检按前缀比对剥除语义。
127
+ */
128
+ export const CAPABILITY_FORBIDDEN: {
129
+ readonly tags: readonly string[]
130
+ readonly attributes: readonly string[]
131
+ } = {
132
+ tags: ['button', 'form', 'iframe', 'input', 'link', 'script', 'style', 'svg'],
133
+ attributes: ['class', 'style', 'data-*', 'on*'],
134
+ }
135
+
136
+ /**
137
+ * 能力段 ①② 的清单渲染(文案由常量渲染,禁手写散文旁路)。渲染顺序 = 常量声明顺序,
138
+ * 包内单测按「解析出的清单集合 === 常量集合」逐项锁定(含常量集合全部成员、无旁路成员
139
+ * ——防对拍机检空转)。
140
+ */
141
+ function renderCapabilityLists(): {
142
+ tags: string
143
+ attrs: string
144
+ forbiddenTags: string
145
+ forbiddenAttrs: string
146
+ } {
147
+ return {
148
+ tags: Object.values(CAPABILITY_INLINE_TAG_FAMILIES).flat().join(', '),
149
+ attrs: CAPABILITY_PRESENTATION_ATTRS.join(', '),
150
+ forbiddenTags: CAPABILITY_FORBIDDEN.tags.join(', '),
151
+ forbiddenAttrs: CAPABILITY_FORBIDDEN.attributes.join(', '),
152
+ }
153
+ }
154
+
155
+ const CAPABILITY_LISTS = renderCapabilityLists()
156
+
157
+ /**
158
+ * taiji capability 固定注入段(chat-html-support 设计 D1):告知 AI 本渲染器的能力面,让新会话
159
+ * 无需用户手动教。常量放扩展源码内(版本化随 feature 走,非用户配置——用户只持有 on/off 开关,
160
+ * 不持有文案本身)。文案英文,与 pi system prompt 语言一致。
161
+ *
162
+ * M0 = ①②(inline HTML 正面清单 + 负面清单,文案由 CAPABILITY_* 常量渲染);
163
+ * M1 = ③④(HTML 产物交付约定 + 预览约束告知,本函数追加)。其余三点 = 相对图片 cwd 解析 /
164
+ * 相对链接 cwd 解析(与反引号白名单路径自动链接化同基准、校验面差异如实)/ 远程图片不渲染
165
+ * + 反引号规约。
166
+ *
167
+ * @param artifactsDir 会话产物目录绝对路径(handler 每 turn 经 ctx 推导后拼入 ③ 段);
168
+ * null = 会话 id 缺失 / 不可解析 → 该处退化为 SESSION_ARTIFACTS_DIR_UNAVAILABLE,
169
+ * 其余文案完整(设计 D1「该行退化」)。
170
+ */
171
+ function renderCapabilitySection(artifactsDir: string | null): string {
172
+ const artifactsRef = artifactsDir ?? SESSION_ARTIFACTS_DIR_UNAVAILABLE
173
+ return `# TaiJi capabilities
174
+
175
+ How TaiJi renders your markdown responses:
176
+
177
+ - Inline HTML is rendered with a GitHub-grade allowlist; anything outside it is stripped before rendering. You may use these tags: ${CAPABILITY_LISTS.tags}. Presentational attributes allowed: ${CAPABILITY_LISTS.attrs}.
178
+ - Do not use these tags (they are stripped before rendering): ${CAPABILITY_LISTS.forbiddenTags}. Do not use these attributes (they are stripped and have no effect): ${CAPABILITY_LISTS.forbiddenAttrs}.
179
+ - For anything larger than a small fragment (reports, charts, interactive pages): write the HTML file into the session artifacts directory ${artifactsRef}${artifactsDir ? ' (given above each turn)' : ''}, then reference it with a fenced block whose info string is html-preview and whose content is that file's absolute path. Never paste the full HTML into your reply after writing the file — update the file in place on later changes. Artifacts are recycled automatically once stale (retention policy) — regenerate on reference failure instead of assuming persistence.
180
+ - Previewed HTML runs sandboxed with no network access: scripts/styles/images may be inline or reference local files by relative path next to the HTML file; fonts must be inlined as data: URIs (relative-path font files are blocked by the browser CORS policy for the opaque-origin preview). CDN links will not load.
181
+ - Relative image paths (e.g. ![](docs/assets/img.png)) are resolved against the session working directory and displayed.
182
+ - Relative links (e.g. [plan](docs/plan.md)) use the same session working directory; clicking one opens the target file inside the app. Backtick file paths that TaiJi auto-links share the same cwd base. Difference in guarantees: auto-linked backtick paths are checked to exist, relative links may be dead — when citing a file you know exists, backticks are the safer form.
183
+ - Remote http(s) images are not rendered; reference a local file path instead. Always use backticks for inline code and type names in prose (bare angle-bracket names like Promise<void> are stripped).`
184
+ }
70
185
 
71
186
  /**
72
187
  * Resolve the data directory from the environment.
@@ -95,6 +210,64 @@ function resolveDataDir(): string {
95
210
  return path.resolve(process.env.PI_CODING_AGENT_DIR ?? '', '..')
96
211
  }
97
212
 
213
+ /**
214
+ * 会话产物目录镜像常量(设计 D1/D7):`<dataDir>/artifacts/<sessionId>/`。
215
+ *
216
+ * shared 侧单点声明 = `packages/shared/src/paths.ts` 的 `getSessionArtifactsDir`;本包
217
+ * 不 import `@taiji/shared`(① 包边界——extensions 包全体不依赖 @taiji/shared;② 运行时
218
+ * 门禁——shared `getDataDir()` 对「非打包进程持有 ~/.taiji 树值」直接 throw(C-proc-26),
219
+ * 而打包态 pi 进程恰构成该组合(C-proc-09 剥了 TAIJI_AGENT_PACKAGED),误用会让打包态
220
+ * capability 段整段静默消失)。故以同公式镜像推导,两侧字面量一致性由 u-artifacts 的
221
+ * 源文件对拍机检守护(§11 检查点 8)。
222
+ */
223
+ export const SESSION_ARTIFACTS_DIR_SEGMENT = 'artifacts'
224
+
225
+ /**
226
+ * 会话产物目录绝对路径(镜像 shared `getSessionArtifactsDir` 公式)。
227
+ *
228
+ * sessionId 校验与 pi `assertValidSessionId` / runtime `isPiSessionId` 同域:首尾字母
229
+ * 数字,中间允许 `[A-Za-z0-9._-]`(允许 `.` / `_`,禁 `:`)——不用 `getImageCacheDir`
230
+ * 的窄集 `/^[A-Za-z0-9_-]+$/`(合法含 `.` 的 pi sid 会被窄集误拒)。非法 sessionId
231
+ * 直接 throw(防路径穿越)。
232
+ *
233
+ * @param sessionId 会话 id(子目录分区,须与 `isPiSessionId` 同域)
234
+ * @throws Error 当 sessionId 含路径分隔符、冒号、空串或首尾非字母数字
235
+ */
236
+ export function resolveSessionArtifactsDir(sessionId: string): string {
237
+ // 校验与 pi-paths.ts 的 isPiSessionId 同域(字面量与 shared getSessionArtifactsDir 一致)
238
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(sessionId)) {
239
+ throw new Error(`invalid sessionId (path traversal blocked): ${sessionId}`)
240
+ }
241
+ return path.join(resolveDataDir(), SESSION_ARTIFACTS_DIR_SEGMENT, sessionId)
242
+ }
243
+
244
+ /**
245
+ * 产物目录不可用时的占位文案(会话 id 缺失 / 不可解析,设计 D1):拼入能力段 ③ 段,
246
+ * 退化只发生在该处,其余文案完整。
247
+ */
248
+ const SESSION_ARTIFACTS_DIR_UNAVAILABLE = '(unavailable this turn)'
249
+
250
+ /**
251
+ * 从 handler 第二参 ctx 推导会话产物目录绝对路径(设计 D1 路径注入):
252
+ * `ctx.sessionManager.getSessionId()` + 包内镜像推导(resolveDataDir + 段名常量,
253
+ * 不经 @taiji/shared——包边界与 C-proc-26/C-proc-09 门禁组合,见 D1)。
254
+ *
255
+ * 会话 id 缺失(无 ctx / getSessionId 返回空)/ 非法(resolveSessionArtifactsDir throw,
256
+ * 如 btw 虚拟 id 含冒号)/ getSessionId 抛错 → null,调用方按退化形态渲染。
257
+ */
258
+ function resolveArtifactsDirFromCtx(ctx?: ExtensionContext): string | null {
259
+ try {
260
+ const manager = ctx?.sessionManager
261
+ const sid = manager ? manager.getSessionId() : ''
262
+ if (!sid) return null
263
+ return resolveSessionArtifactsDir(sid)
264
+ } catch (err) {
265
+ // best-effort:本 turn 路径缺失 → 退化形态(绝不把异常漏给 agent loop)
266
+ logger.debug('session artifacts dir unavailable this turn', { detail: String(err) })
267
+ return null
268
+ }
269
+ }
270
+
98
271
  /**
99
272
  * Resolve the global agents directory.
100
273
  *
@@ -152,30 +325,42 @@ function readGlobalAgentsFile(): { path: string; content: string } | null {
152
325
  /**
153
326
  * Read & parse the config file. Missing / malformed / partial → all-default.
154
327
  * Returns the effective config object; never throws.
328
+ *
329
+ * Only parses the sections this extension consumes (append / capability).
330
+ * The config file's `version` / `replace` fields belong to the runtime-side
331
+ * `--system-prompt` consumer (ADR-0044) and are intentionally not parsed here.
155
332
  */
156
333
  function readConfig(dataDir: string): {
157
- version: number
158
- replace: { enabled: boolean; prompt: string }
159
334
  append: { enabled: boolean; prompt: string }
335
+ capability: { enabled: boolean }
160
336
  } {
161
337
  const parsed = readJsonIfValid(path.join(dataDir, CONFIG_FILE))
162
338
  if (!parsed) {
163
339
  return {
164
- version: 1,
165
- replace: { enabled: false, prompt: '' },
166
340
  append: { enabled: false, prompt: '' },
341
+ // capability 默认值与解析语义同向:缺 config → 开(见 readCapabilityEnabled)
342
+ capability: { enabled: true },
167
343
  }
168
344
  }
169
345
  // Merge defensively — every field has its own default.
170
- // replace 字段仅防御性解析保持 config 结构完整,不参与本 hook 逻辑——
171
- // replace 走 --system-prompt CLI(ADR-0044),hook 只处理 append。
172
346
  return {
173
- version: typeof parsed.version === 'number' ? parsed.version : 1,
174
- replace: readSection(parsed.replace),
175
347
  append: readSection(parsed.append),
348
+ capability: { enabled: readCapabilityEnabled(parsed.capability) },
176
349
  }
177
350
  }
178
351
 
352
+ /**
353
+ * capability 段开关解析——方向与 readSection 刻意相反(设计 D6 防照抄锚点):
354
+ * 仅显式布尔 `false` 关闭;缺字段(v1 存量 json)/字段形态不对(如字符串
355
+ * "false")/capability 非对象/文件损坏(readConfig 前置收敛)→ true。
356
+ * 原因:replace/append 是用户显式配置(缺省关闭才安全),capability 是 taiji
357
+ * 内置告知(默认开是交付语义)——fail-safe 方向各自服务于所属字段的语义。
358
+ */
359
+ function readCapabilityEnabled(raw: unknown): boolean {
360
+ if (!isJsonObject(raw)) return true
361
+ return raw.enabled !== false
362
+ }
363
+
179
364
  /** Read a JSON file and return it as an object; missing / malformed / non-object → null. */
180
365
  function readJsonIfValid(filePath: string): Record<string, unknown> | null {
181
366
  try {
@@ -192,7 +377,7 @@ function isJsonObject(v: unknown): v is Record<string, unknown> {
192
377
  return !!v && typeof v === 'object'
193
378
  }
194
379
 
195
- /** Defensive field parsing for a `replace`/`append` config section. */
380
+ /** Defensive field parsing for the `append` config section. */
196
381
  function readSection(raw: unknown): { enabled: boolean; prompt: string } {
197
382
  const section = isJsonObject(raw) ? raw : {}
198
383
  return {
@@ -202,8 +387,8 @@ function readSection(raw: unknown): { enabled: boolean; prompt: string } {
202
387
  }
203
388
 
204
389
  /**
205
- * pi 是否以 --no-context-files / -nc 启动。用户显式退出 AGENTS.md / CLAUDE.md
206
- * 发现时,全局文件不得从这条通路溜回来。pi CLI 把 -nc 视为 --no-context-files
390
+ * pi 是否以 --no-context-files / -nc 启动。用户显式退出 AGENTS.md
391
+ * 自动发现时,全局文件不得从这条通路溜回来。pi CLI 把 -nc 视为 --no-context-files
207
392
  * 的等价短形式(cli/args.ts),两种形式都必须命中守卫——与镜像侧
208
393
  * (argv-mirror.ts 同样解析两种形式)保持一致。
209
394
  */
@@ -219,6 +404,18 @@ function withGlobalInstructions(prompt: string): string {
219
404
  return prompt + '\n\n# Global instructions (' + global.path + ')\n\n' + global.content
220
405
  }
221
406
 
407
+ /**
408
+ * Append the fixed taiji capability section(labeled header 与 global 注入段同构)。
409
+ * 生效粒度 = 下一 turn:本 hook 每 turn 读 config(mtime 缓存判变),改开关 → 写
410
+ * system-prompt.json → 下一 turn 读到新值——当前 turn 不受影响(机制既有,非新增)。
411
+ * 会话产物目录路径每 turn 经 ctx 重新推导(会话切换/新建即生效)。
412
+ */
413
+ function withCapabilitySection(prompt: string, ctx?: ExtensionContext): string {
414
+ const cfg = readConfig(resolveDataDir())
415
+ if (!cfg.capability.enabled) return prompt
416
+ return prompt + '\n\n' + renderCapabilitySection(resolveArtifactsDirFromCtx(ctx))
417
+ }
418
+
222
419
  /** Read the append config and apply it to the prompt (empty append → unchanged). */
223
420
  function withAppendPrompt(prompt: string): string {
224
421
  const cfg = readConfig(resolveDataDir())
@@ -228,13 +425,13 @@ function withAppendPrompt(prompt: string): string {
228
425
 
229
426
  /**
230
427
  * Build the injected system prompt. Injection order per turn:
231
- * base prompt → global instructions → append config (the explicitly
232
- * configured text wins last). Returns the new systemPrompt, or undefined
233
- * when nothing changed.
428
+ * base prompt → global instructions → taiji capability section → append
429
+ * config (the explicitly configured text wins last). Returns the new
430
+ * systemPrompt, or undefined when nothing changed.
234
431
  */
235
- function buildSystemPrompt(event: BeforeAgentStartEvent): { systemPrompt: string } | undefined {
432
+ function buildSystemPrompt(event: BeforeAgentStartEvent, ctx?: ExtensionContext): { systemPrompt: string } | undefined {
236
433
  const basePrompt = typeof event.systemPrompt === 'string' ? event.systemPrompt : ''
237
- const newPrompt = withAppendPrompt(withGlobalInstructions(basePrompt))
434
+ const newPrompt = withAppendPrompt(withCapabilitySection(withGlobalInstructions(basePrompt), ctx))
238
435
  return newPrompt === event.systemPrompt ? undefined : { systemPrompt: newPrompt }
239
436
  }
240
437
 
@@ -261,9 +458,9 @@ function logHookFailure(err: unknown): void {
261
458
  }
262
459
 
263
460
  export default function (pi: ExtensionAPI): void {
264
- pi.on('before_agent_start', (event: BeforeAgentStartEvent) => {
461
+ pi.on('before_agent_start', (event: BeforeAgentStartEvent, ctx: ExtensionContext) => {
265
462
  try {
266
- return buildSystemPrompt(event)
463
+ return buildSystemPrompt(event, ctx)
267
464
  } catch (err) {
268
465
  // Never block the agent loop.
269
466
  logHookFailure(err)