@zhushanwen/pi-system-prompt 1.2.0 → 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 +193 -19
- package/src/index.ts +180 -31
package/package.json
CHANGED
|
@@ -33,7 +33,13 @@ vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
|
33
33
|
|
|
34
34
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
35
35
|
import path from 'node:path'
|
|
36
|
-
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'
|
|
37
43
|
import type { ExtensionAPI, BeforeAgentStartEvent } from '@earendil-works/pi-coding-agent'
|
|
38
44
|
|
|
39
45
|
vi.mock('node:fs', () => ({
|
|
@@ -51,8 +57,8 @@ const CAP_HEADER = '# TaiJi capabilities'
|
|
|
51
57
|
/** 既有用例隔离关注点用的「capability 显式关闭」config 片段 */
|
|
52
58
|
const CAP_OFF = '"capability": {"enabled": false}'
|
|
53
59
|
|
|
54
|
-
/** hook 注册表桩(参照 msg-id-mapper harness
|
|
55
|
-
function createHarness(): { beforeAgentStart: (event: BeforeAgentStartEvent) => unknown } {
|
|
60
|
+
/** hook 注册表桩(参照 msg-id-mapper harness 模式)——handler 第二参 ctx 透传(路径注入测试用) */
|
|
61
|
+
function createHarness(): { beforeAgentStart: (event: BeforeAgentStartEvent, ctx?: unknown) => unknown } {
|
|
56
62
|
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
|
57
63
|
const pi = {
|
|
58
64
|
on: (event: string, handler: (...args: unknown[]) => unknown) => {
|
|
@@ -61,14 +67,14 @@ function createHarness(): { beforeAgentStart: (event: BeforeAgentStartEvent) =>
|
|
|
61
67
|
} as unknown as ExtensionAPI
|
|
62
68
|
createExtension(pi)
|
|
63
69
|
return {
|
|
64
|
-
beforeAgentStart: (event) => handlers.get('before_agent_start')!(event),
|
|
70
|
+
beforeAgentStart: (event, ctx) => handlers.get('before_agent_start')!(event, ctx),
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
/** 触发一次 hook 的便捷封装(常规 event:systemPrompt
|
|
69
|
-
function runHook(systemPrompt: string): { systemPrompt?: string } | undefined {
|
|
74
|
+
/** 触发一次 hook 的便捷封装(常规 event:systemPrompt 字符串;ctx 可选) */
|
|
75
|
+
function runHook(systemPrompt: string, ctx?: unknown): { systemPrompt?: string } | undefined {
|
|
70
76
|
const h = createHarness()
|
|
71
|
-
return h.beforeAgentStart({ type: 'before_agent_start', prompt: 'hi', systemPrompt }) as
|
|
77
|
+
return h.beforeAgentStart({ type: 'before_agent_start', prompt: 'hi', systemPrompt }, ctx) as
|
|
72
78
|
| { systemPrompt?: string }
|
|
73
79
|
| undefined
|
|
74
80
|
}
|
|
@@ -323,7 +329,7 @@ describe('-nc / --no-context-files 守卫(contextFilesDisabled)', () => {
|
|
|
323
329
|
expect(readdirSync).not.toHaveBeenCalled()
|
|
324
330
|
})
|
|
325
331
|
|
|
326
|
-
it('argv 含 -nc 短形式 → 同样跳过 global
|
|
332
|
+
it('argv 含 -nc 短形式 → 同样跳过 global 注入(pi CLI 等价短形式,守卫两种形式都命中)', () => {
|
|
327
333
|
setupFs({
|
|
328
334
|
config: `{"append": {"enabled": true, "prompt": "APPEND-TEXT"}, ${CAP_OFF}}`,
|
|
329
335
|
globalEntries: ['AGENTS.md'],
|
|
@@ -335,25 +341,25 @@ describe('-nc / --no-context-files 守卫(contextFilesDisabled)', () => {
|
|
|
335
341
|
})
|
|
336
342
|
|
|
337
343
|
describe('global 候选文件选择(readGlobalAgentsFile)', () => {
|
|
338
|
-
it('候选序优先:AGENTS.
|
|
344
|
+
it('候选序优先:AGENTS.md 与 AGENTS.MD 并存 → AGENTS.md 胜(候选列表顺序的第一个存在者)', () => {
|
|
339
345
|
setupFs({
|
|
340
|
-
globalEntries: ['AGENTS.
|
|
341
|
-
globalFiles: { 'AGENTS.
|
|
346
|
+
globalEntries: ['AGENTS.md', 'AGENTS.MD'],
|
|
347
|
+
globalFiles: { 'AGENTS.md': 'FROM-LOWER', 'AGENTS.MD': 'FROM-UPPER' },
|
|
342
348
|
})
|
|
343
349
|
const result = runHook('BASE-PROMPT')
|
|
344
|
-
expect(result!.systemPrompt).toContain('FROM-
|
|
345
|
-
expect(result!.systemPrompt).not.toContain('FROM-
|
|
346
|
-
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'))
|
|
347
353
|
})
|
|
348
354
|
|
|
349
|
-
it('首候选内容空白 → 跳过继续找下一候选(AGENTS.md 空白 +
|
|
355
|
+
it('首候选内容空白 → 跳过继续找下一候选(AGENTS.md 空白 + AGENTS.MD 有内容 → 注入 AGENTS.MD)', () => {
|
|
350
356
|
setupFs({
|
|
351
|
-
globalEntries: ['AGENTS.md', '
|
|
352
|
-
globalFiles: { 'AGENTS.md': ' \n\t', '
|
|
357
|
+
globalEntries: ['AGENTS.md', 'AGENTS.MD'],
|
|
358
|
+
globalFiles: { 'AGENTS.md': ' \n\t', 'AGENTS.MD': 'FROM-UPPER' },
|
|
353
359
|
})
|
|
354
360
|
const result = runHook('BASE-PROMPT')
|
|
355
|
-
expect(result!.systemPrompt).toContain('FROM-
|
|
356
|
-
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'))
|
|
357
363
|
})
|
|
358
364
|
|
|
359
365
|
it('global 目录不存在(readdirSync throw)→ 降级 null:global 不注入、不抛错(capability 默认段仍在)', () => {
|
|
@@ -451,3 +457,171 @@ describe('cachedReadFileSync(mtime 级内容缓存,KV-cache 稳定性改造
|
|
|
451
457
|
expect(runHook('base')).toEqual({ systemPrompt: expect.stringContaining(CAP_HEADER) })
|
|
452
458
|
})
|
|
453
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()
|
|
626
|
+
})
|
|
627
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 3. When `append.enabled === true` and `append.prompt` is non-blank,
|
|
12
12
|
* appends the user's text to the event's systemPrompt.
|
|
13
13
|
* 4. Reads the global instructions file `~/.agents/AGENTS.md` (candidates
|
|
14
|
-
* AGENTS.md / AGENTS.MD
|
|
14
|
+
* AGENTS.md / AGENTS.MD, exact case match) every turn and appends it
|
|
15
15
|
* under a labeled header. Modeled on pi's native `loadContextFileFromDir`
|
|
16
16
|
* but deliberately narrower: pi 0.84.4 also probes `AGENTS.override.md`
|
|
17
17
|
* and applies its candidate list to project dirs, whereas this list only
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import path from 'node:path'
|
|
33
33
|
import { homedir } from 'node:os'
|
|
34
34
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
35
|
-
import type { ExtensionAPI, BeforeAgentStartEvent } from '@earendil-works/pi-coding-agent'
|
|
35
|
+
import type { ExtensionAPI, BeforeAgentStartEvent, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
36
36
|
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
37
37
|
|
|
38
38
|
const logger = getLogger('taiji-system-prompt-extension')
|
|
@@ -66,28 +66,122 @@ function cachedReadFileSync(filePath: string): string | null {
|
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Global instruction candidates. Modeled on pi's native loadContextFileFromDir
|
|
69
|
-
* but deliberately not a strict mirror: pi 0.84.4 probes
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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.
|
|
73
73
|
*/
|
|
74
|
-
const GLOBAL_AGENTS_CANDIDATES = ['AGENTS.md', 'AGENTS.MD'
|
|
74
|
+
const GLOBAL_AGENTS_CANDIDATES = ['AGENTS.md', 'AGENTS.MD']
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
+
* 通配项以 `*` 结尾,对拍机检按前缀比对剥除语义。
|
|
82
127
|
*/
|
|
83
|
-
const
|
|
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
|
|
84
174
|
|
|
85
175
|
How TaiJi renders your markdown responses:
|
|
86
176
|
|
|
87
|
-
- Inline HTML is rendered with a GitHub-grade
|
|
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.
|
|
88
181
|
- Relative image paths (e.g. ) are resolved against the session working directory and displayed.
|
|
89
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.
|
|
90
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
|
+
}
|
|
91
185
|
|
|
92
186
|
/**
|
|
93
187
|
* Resolve the data directory from the environment.
|
|
@@ -116,6 +210,64 @@ function resolveDataDir(): string {
|
|
|
116
210
|
return path.resolve(process.env.PI_CODING_AGENT_DIR ?? '', '..')
|
|
117
211
|
}
|
|
118
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
|
+
|
|
119
271
|
/**
|
|
120
272
|
* Resolve the global agents directory.
|
|
121
273
|
*
|
|
@@ -173,29 +325,25 @@ function readGlobalAgentsFile(): { path: string; content: string } | null {
|
|
|
173
325
|
/**
|
|
174
326
|
* Read & parse the config file. Missing / malformed / partial → all-default.
|
|
175
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.
|
|
176
332
|
*/
|
|
177
333
|
function readConfig(dataDir: string): {
|
|
178
|
-
version: number
|
|
179
|
-
replace: { enabled: boolean; prompt: string }
|
|
180
334
|
append: { enabled: boolean; prompt: string }
|
|
181
335
|
capability: { enabled: boolean }
|
|
182
336
|
} {
|
|
183
337
|
const parsed = readJsonIfValid(path.join(dataDir, CONFIG_FILE))
|
|
184
338
|
if (!parsed) {
|
|
185
339
|
return {
|
|
186
|
-
version: 1,
|
|
187
|
-
replace: { enabled: false, prompt: '' },
|
|
188
340
|
append: { enabled: false, prompt: '' },
|
|
189
341
|
// capability 默认值与解析语义同向:缺 config → 开(见 readCapabilityEnabled)
|
|
190
342
|
capability: { enabled: true },
|
|
191
343
|
}
|
|
192
344
|
}
|
|
193
345
|
// Merge defensively — every field has its own default.
|
|
194
|
-
// replace 字段仅防御性解析保持 config 结构完整,不参与本 hook 逻辑——
|
|
195
|
-
// replace 走 --system-prompt CLI(ADR-0044),hook 只处理 append。
|
|
196
346
|
return {
|
|
197
|
-
version: typeof parsed.version === 'number' ? parsed.version : 1,
|
|
198
|
-
replace: readSection(parsed.replace),
|
|
199
347
|
append: readSection(parsed.append),
|
|
200
348
|
capability: { enabled: readCapabilityEnabled(parsed.capability) },
|
|
201
349
|
}
|
|
@@ -229,7 +377,7 @@ function isJsonObject(v: unknown): v is Record<string, unknown> {
|
|
|
229
377
|
return !!v && typeof v === 'object'
|
|
230
378
|
}
|
|
231
379
|
|
|
232
|
-
/** Defensive field parsing for
|
|
380
|
+
/** Defensive field parsing for the `append` config section. */
|
|
233
381
|
function readSection(raw: unknown): { enabled: boolean; prompt: string } {
|
|
234
382
|
const section = isJsonObject(raw) ? raw : {}
|
|
235
383
|
return {
|
|
@@ -239,8 +387,8 @@ function readSection(raw: unknown): { enabled: boolean; prompt: string } {
|
|
|
239
387
|
}
|
|
240
388
|
|
|
241
389
|
/**
|
|
242
|
-
* pi 是否以 --no-context-files / -nc 启动。用户显式退出 AGENTS.md
|
|
243
|
-
*
|
|
390
|
+
* pi 是否以 --no-context-files / -nc 启动。用户显式退出 AGENTS.md
|
|
391
|
+
* 自动发现时,全局文件不得从这条通路溜回来。pi CLI 把 -nc 视为 --no-context-files
|
|
244
392
|
* 的等价短形式(cli/args.ts),两种形式都必须命中守卫——与镜像侧
|
|
245
393
|
* (argv-mirror.ts 同样解析两种形式)保持一致。
|
|
246
394
|
*/
|
|
@@ -260,11 +408,12 @@ function withGlobalInstructions(prompt: string): string {
|
|
|
260
408
|
* Append the fixed taiji capability section(labeled header 与 global 注入段同构)。
|
|
261
409
|
* 生效粒度 = 下一 turn:本 hook 每 turn 读 config(mtime 缓存判变),改开关 → 写
|
|
262
410
|
* system-prompt.json → 下一 turn 读到新值——当前 turn 不受影响(机制既有,非新增)。
|
|
411
|
+
* 会话产物目录路径每 turn 经 ctx 重新推导(会话切换/新建即生效)。
|
|
263
412
|
*/
|
|
264
|
-
function withCapabilitySection(prompt: string): string {
|
|
413
|
+
function withCapabilitySection(prompt: string, ctx?: ExtensionContext): string {
|
|
265
414
|
const cfg = readConfig(resolveDataDir())
|
|
266
415
|
if (!cfg.capability.enabled) return prompt
|
|
267
|
-
return prompt + '\n\n' +
|
|
416
|
+
return prompt + '\n\n' + renderCapabilitySection(resolveArtifactsDirFromCtx(ctx))
|
|
268
417
|
}
|
|
269
418
|
|
|
270
419
|
/** Read the append config and apply it to the prompt (empty append → unchanged). */
|
|
@@ -280,9 +429,9 @@ function withAppendPrompt(prompt: string): string {
|
|
|
280
429
|
* config (the explicitly configured text wins last). Returns the new
|
|
281
430
|
* systemPrompt, or undefined when nothing changed.
|
|
282
431
|
*/
|
|
283
|
-
function buildSystemPrompt(event: BeforeAgentStartEvent): { systemPrompt: string } | undefined {
|
|
432
|
+
function buildSystemPrompt(event: BeforeAgentStartEvent, ctx?: ExtensionContext): { systemPrompt: string } | undefined {
|
|
284
433
|
const basePrompt = typeof event.systemPrompt === 'string' ? event.systemPrompt : ''
|
|
285
|
-
const newPrompt = withAppendPrompt(withCapabilitySection(withGlobalInstructions(basePrompt)))
|
|
434
|
+
const newPrompt = withAppendPrompt(withCapabilitySection(withGlobalInstructions(basePrompt), ctx))
|
|
286
435
|
return newPrompt === event.systemPrompt ? undefined : { systemPrompt: newPrompt }
|
|
287
436
|
}
|
|
288
437
|
|
|
@@ -309,9 +458,9 @@ function logHookFailure(err: unknown): void {
|
|
|
309
458
|
}
|
|
310
459
|
|
|
311
460
|
export default function (pi: ExtensionAPI): void {
|
|
312
|
-
pi.on('before_agent_start', (event: BeforeAgentStartEvent) => {
|
|
461
|
+
pi.on('before_agent_start', (event: BeforeAgentStartEvent, ctx: ExtensionContext) => {
|
|
313
462
|
try {
|
|
314
|
-
return buildSystemPrompt(event)
|
|
463
|
+
return buildSystemPrompt(event, ctx)
|
|
315
464
|
} catch (err) {
|
|
316
465
|
// Never block the agent loop.
|
|
317
466
|
logHookFailure(err)
|