@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 +1 -1
- package/src/__tests__/system-prompt.test.ts +326 -58
- package/src/index.ts +226 -29
package/package.json
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
|
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
|
-
/**
|
|
44
|
-
|
|
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
|
-
|
|
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')).
|
|
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')).
|
|
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')).
|
|
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')).
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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('
|
|
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
|
|
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(
|
|
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('
|
|
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
|
-
|
|
225
|
-
expect(
|
|
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:
|
|
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
|
|
332
|
+
it('argv 含 -nc 短形式 → 同样跳过 global 注入(pi CLI 等价短形式,守卫两种形式都命中)', () => {
|
|
243
333
|
setupFs({
|
|
244
|
-
config:
|
|
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.
|
|
344
|
+
it('候选序优先:AGENTS.md 与 AGENTS.MD 并存 → AGENTS.md 胜(候选列表顺序的第一个存在者)', () => {
|
|
255
345
|
setupFs({
|
|
256
|
-
globalEntries: ['AGENTS.
|
|
257
|
-
globalFiles: { 'AGENTS.
|
|
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-
|
|
261
|
-
expect(result!.systemPrompt).not.toContain('FROM-
|
|
262
|
-
expect(result!.systemPrompt).toContain(path.join(GLOBAL_DIR, 'AGENTS.
|
|
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 空白 +
|
|
355
|
+
it('首候选内容空白 → 跳过继续找下一候选(AGENTS.md 空白 + AGENTS.MD 有内容 → 注入 AGENTS.MD)', () => {
|
|
266
356
|
setupFs({
|
|
267
|
-
globalEntries: ['AGENTS.md', '
|
|
268
|
-
globalFiles: { 'AGENTS.md': ' \n\t', '
|
|
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-
|
|
272
|
-
expect(result!.systemPrompt).toContain(path.join(GLOBAL_DIR, '
|
|
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
|
-
|
|
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
|
|
406
|
+
it('systemPrompt 非法类型(undefined)且无 append → 旧 quirk 锚定:返回仅含 capability 段的结果而非 undefined', () => {
|
|
315
407
|
// R3 复核锚定的返回值守卫 quirk(index.ts newPrompt === event.systemPrompt 比较):
|
|
316
|
-
// base 收敛 '',
|
|
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 注入 →
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
354
|
-
setupFs({ config:
|
|
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')).
|
|
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.
|
|
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
|
-
*
|
|
10
|
-
* AGENTS.md / AGENTS.MD
|
|
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 →
|
|
21
|
-
* (the explicitly configured text wins
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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'
|
|
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. ) 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
|
|
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
|
|
206
|
-
*
|
|
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 →
|
|
232
|
-
* configured text wins last). Returns the new
|
|
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)
|