@zhushanwen/pi-system-prompt 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +1 -0
- package/package.json +34 -0
- package/src/__tests__/system-prompt.test.ts +311 -0
- package/src/index.ts +212 -0
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-system-prompt",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "System prompt injection extension for Pi — reads config and appends to system prompt",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package",
|
|
14
|
+
"extension",
|
|
15
|
+
"system-prompt",
|
|
16
|
+
"config"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"files": [
|
|
20
|
+
"src/",
|
|
21
|
+
"index.ts"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^24.0.0",
|
|
28
|
+
"vitest": "^4.1.8"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"typecheck": "npx tsc --noEmit",
|
|
32
|
+
"test": "vitest run"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* system-prompt extension 真实行为测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(替换原 expect(true) 占位,R3 extension-api SUGGESTION #1):
|
|
5
|
+
* - readJsonIfValid 解析边界:文件缺失 / 畸形 JSON / 顶层 array / 顶层原始值 → 全部收敛 defaults
|
|
6
|
+
* - readSection 字段级防御:section 非对象、enabled 非 true、prompt 非字符串 / 空白 → 不注入
|
|
7
|
+
* - before_agent_start 注入顺序:base → global instructions → append config(indexOf 链锁定,
|
|
8
|
+
* swap 注入顺序两行的 mutant 会被顺序用例 kill)
|
|
9
|
+
* - -nc / --no-context-files 守卫:global 注入跳过、append 不受影响
|
|
10
|
+
* - global 候选选择:候选序优先、空白内容跳过继续找、目录缺失降级
|
|
11
|
+
* - fail-safe:handler 全程 throw → return undefined + stderr 可观测;stderr 写失败的
|
|
12
|
+
* 终极兜底 console.debug;systemPrompt 非法类型的旧 quirk 锚定
|
|
13
|
+
*
|
|
14
|
+
* mock 策略(参照 msg-id-mapper 测试模式):pi SDK import type 零运行时解析,
|
|
15
|
+
* ExtensionAPI 用结构化桩;node:fs mock 后按路径分流(env 指向假目录,不碰真实文件系统)。
|
|
16
|
+
*
|
|
17
|
+
* 运行:cd extensions/system-prompt && npx vitest run
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
20
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import createExtension from '../index'
|
|
23
|
+
import type { ExtensionAPI, BeforeAgentStartEvent } from '@earendil-works/pi-coding-agent'
|
|
24
|
+
|
|
25
|
+
vi.mock('node:fs', () => ({
|
|
26
|
+
readFileSync: vi.fn(),
|
|
27
|
+
readdirSync: vi.fn(),
|
|
28
|
+
statSync: vi.fn(),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
const DATA_DIR = '/xyz-test/data'
|
|
32
|
+
const GLOBAL_DIR = '/xyz-test/global-agents'
|
|
33
|
+
const CONFIG_PATH = path.join(DATA_DIR, 'system-prompt.json')
|
|
34
|
+
|
|
35
|
+
/** hook 注册表桩(参照 msg-id-mapper harness 模式) */
|
|
36
|
+
function createHarness(): { beforeAgentStart: (event: BeforeAgentStartEvent) => unknown } {
|
|
37
|
+
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
|
38
|
+
const pi = {
|
|
39
|
+
on: (event: string, handler: (...args: unknown[]) => unknown) => {
|
|
40
|
+
handlers.set(event, handler)
|
|
41
|
+
},
|
|
42
|
+
} as unknown as ExtensionAPI
|
|
43
|
+
createExtension(pi)
|
|
44
|
+
return {
|
|
45
|
+
beforeAgentStart: (event) => handlers.get('before_agent_start')!(event),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 触发一次 hook 的便捷封装(常规 event:systemPrompt 字符串) */
|
|
50
|
+
function runHook(systemPrompt: string): { systemPrompt?: string } | undefined {
|
|
51
|
+
const h = createHarness()
|
|
52
|
+
return h.beforeAgentStart({ type: 'before_agent_start', prompt: 'hi', systemPrompt }) as
|
|
53
|
+
| { systemPrompt?: string }
|
|
54
|
+
| undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* fs mock 分流配置。
|
|
59
|
+
* - config:system-prompt.json 的文件内容(string)或 Error(readFileSync throw,默认 ENOENT)
|
|
60
|
+
* - globalEntries:global 目录 readdirSync 返回(默认 [] = 无候选)
|
|
61
|
+
* - globalFiles:候选文件名 → 内容(string)或 Error(stat/read throw)
|
|
62
|
+
*/
|
|
63
|
+
interface FsSetup {
|
|
64
|
+
config?: string | Error
|
|
65
|
+
globalEntries?: string[] | Error
|
|
66
|
+
globalFiles?: Record<string, string | Error>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function setupFs(setup: FsSetup = {}): void {
|
|
70
|
+
const { config = new Error("ENOENT: no such file or directory, open '" + CONFIG_PATH + "'") } = setup
|
|
71
|
+
const { globalEntries = [], globalFiles = {} } = setup
|
|
72
|
+
vi.mocked(readdirSync).mockImplementation(() => {
|
|
73
|
+
if (globalEntries instanceof Error) throw globalEntries
|
|
74
|
+
return globalEntries as unknown as string[]
|
|
75
|
+
})
|
|
76
|
+
vi.mocked(statSync).mockImplementation((p: unknown) => {
|
|
77
|
+
const name = path.basename(String(p))
|
|
78
|
+
if (!(name in globalFiles)) throw new Error('ENOENT stat ' + String(p))
|
|
79
|
+
if (globalFiles[name] instanceof Error) throw globalFiles[name]
|
|
80
|
+
return { isFile: () => true } as unknown as ReturnType<typeof statSync>
|
|
81
|
+
})
|
|
82
|
+
vi.mocked(readFileSync).mockImplementation((p: unknown) => {
|
|
83
|
+
const fp = String(p)
|
|
84
|
+
if (fp === CONFIG_PATH) {
|
|
85
|
+
if (config instanceof Error) throw config
|
|
86
|
+
return config
|
|
87
|
+
}
|
|
88
|
+
const name = path.basename(fp)
|
|
89
|
+
if (name in globalFiles) {
|
|
90
|
+
if (globalFiles[name] instanceof Error) throw globalFiles[name]
|
|
91
|
+
return globalFiles[name]
|
|
92
|
+
}
|
|
93
|
+
throw new Error('ENOENT: no such file, open ' + fp)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ENV_KEYS = ['XYZ_AGENT_DATA_DIR', 'XYZ_GLOBAL_AGENTS_DIR', 'PI_CODING_AGENT_DIR'] as const
|
|
98
|
+
const savedEnv: Record<string, string | undefined> = {}
|
|
99
|
+
const savedArgv = process.argv
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.clearAllMocks() // 清跨用例的 fs mock 调用记录(「守卫不触发」类断言依赖零计数)
|
|
103
|
+
for (const k of ENV_KEYS) savedEnv[k] = process.env[k]
|
|
104
|
+
process.env.XYZ_AGENT_DATA_DIR = DATA_DIR
|
|
105
|
+
process.env.XYZ_GLOBAL_AGENTS_DIR = GLOBAL_DIR
|
|
106
|
+
delete process.env.PI_CODING_AGENT_DIR
|
|
107
|
+
setupFs()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
for (const k of ENV_KEYS) {
|
|
112
|
+
if (savedEnv[k] === undefined) delete process.env[k]
|
|
113
|
+
else process.env[k] = savedEnv[k]
|
|
114
|
+
}
|
|
115
|
+
process.argv = savedArgv
|
|
116
|
+
vi.restoreAllMocks()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('readJsonIfValid 解析边界(config 读取 → defaults 收敛)', () => {
|
|
120
|
+
it('config 文件缺失(ENOENT)→ defaults → 无注入,返回 undefined', () => {
|
|
121
|
+
setupFs({ config: new Error("ENOENT: no such file or directory, open '" + CONFIG_PATH + "'") })
|
|
122
|
+
expect(runHook('base prompt')).toBeUndefined()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('config 畸形 JSON(parse throw)→ defaults → 无注入', () => {
|
|
126
|
+
setupFs({ config: '{broken json' })
|
|
127
|
+
expect(runHook('base prompt')).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('config 顶层 array → isJsonObject 放行 quirk(数组不排除)→ 字段缺省收敛 defaults → 无注入', () => {
|
|
131
|
+
// R3 复核锚定的行为等价:顶层数组两版实现同走 typeof object 放行路径,无错误数据
|
|
132
|
+
setupFs({ config: '[1, 2]' })
|
|
133
|
+
expect(runHook('base prompt')).toBeUndefined()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('config 顶层原始值(number / string / null 字面量)→ null → defaults → 无注入', () => {
|
|
137
|
+
for (const bad of ['42', '"a string"', 'null', 'true']) {
|
|
138
|
+
setupFs({ config: bad })
|
|
139
|
+
expect(runHook('base prompt')).toBeUndefined()
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('readSection 字段级防御(append section)', () => {
|
|
145
|
+
it('append section 非对象(null / 字符串)→ {enabled:false, prompt:""} → 不注入', () => {
|
|
146
|
+
for (const section of ['null', '"just text"']) {
|
|
147
|
+
setupFs({ config: `{"append": ${section}}` })
|
|
148
|
+
expect(runHook('base prompt')).toBeUndefined()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('append.enabled 非 true(字符串 "true")→ 不视为开启 → 不注入', () => {
|
|
153
|
+
setupFs({ config: '{"append": {"enabled": "true", "prompt": "extra"}}' })
|
|
154
|
+
expect(runHook('base prompt')).toBeUndefined()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('append.prompt 非字符串(number)→ 缺省 "" → 不注入', () => {
|
|
158
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": 123}}' })
|
|
159
|
+
expect(runHook('base prompt')).toBeUndefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('append.enabled true 但 prompt 纯空白 → trim 后为空 → 不注入', () => {
|
|
163
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": " \\n\\t "}}' })
|
|
164
|
+
expect(runHook('base prompt')).toBeUndefined()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('append 合法(enabled true + 非空 prompt)→ 注入到 base 之后(\\n\\n 分隔)', () => {
|
|
168
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}' })
|
|
169
|
+
expect(runHook('base prompt')).toEqual({ systemPrompt: 'base prompt\n\nAPPEND-TEXT' })
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('before_agent_start 注入顺序(base → global → append)', () => {
|
|
174
|
+
it('三段齐备 → base 在前、global 段居中、append 文本最后(indexOf 链锁定)', () => {
|
|
175
|
+
setupFs({
|
|
176
|
+
config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}',
|
|
177
|
+
globalEntries: ['AGENTS.md'],
|
|
178
|
+
globalFiles: { 'AGENTS.md': 'GLOBAL-CONTENT' },
|
|
179
|
+
})
|
|
180
|
+
const result = runHook('BASE-PROMPT')
|
|
181
|
+
expect(result).toEqual({ systemPrompt: expect.stringContaining('APPEND-TEXT') })
|
|
182
|
+
const prompt = result!.systemPrompt as string
|
|
183
|
+
|
|
184
|
+
// 顺序锚点:base 最前 → global header → global 内容 → append 文本最后
|
|
185
|
+
const iBase = prompt.indexOf('BASE-PROMPT')
|
|
186
|
+
const iHeader = prompt.indexOf('# Global instructions')
|
|
187
|
+
const iGlobal = prompt.indexOf('GLOBAL-CONTENT')
|
|
188
|
+
const iAppend = prompt.indexOf('APPEND-TEXT')
|
|
189
|
+
expect(iBase).toBeGreaterThanOrEqual(0)
|
|
190
|
+
expect(iHeader).toBeGreaterThan(iBase)
|
|
191
|
+
expect(iGlobal).toBeGreaterThan(iHeader)
|
|
192
|
+
expect(iAppend).toBeGreaterThan(iGlobal)
|
|
193
|
+
expect(prompt.indexOf('APPEND-TEXT', iAppend + 1)).toBe(-1) // append 恰一次
|
|
194
|
+
// global header 带真实注入路径(可追溯)
|
|
195
|
+
expect(prompt).toContain(path.join(GLOBAL_DIR, 'AGENTS.md'))
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('global 无候选文件 → 只剩 base + append 两段', () => {
|
|
199
|
+
setupFs({
|
|
200
|
+
config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}',
|
|
201
|
+
globalEntries: [],
|
|
202
|
+
})
|
|
203
|
+
const result = runHook('BASE-PROMPT')
|
|
204
|
+
expect(result).toEqual({ systemPrompt: 'BASE-PROMPT\n\nAPPEND-TEXT' })
|
|
205
|
+
expect(result!.systemPrompt).not.toContain('# Global instructions')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('-nc / --no-context-files 守卫(contextFilesDisabled)', () => {
|
|
210
|
+
it('argv 含 --no-context-files → global 不注入,append 仍生效(用户显式退出不得溜回来)', () => {
|
|
211
|
+
setupFs({
|
|
212
|
+
config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}',
|
|
213
|
+
globalEntries: ['AGENTS.md'],
|
|
214
|
+
globalFiles: { 'AGENTS.md': 'GLOBAL-CONTENT' },
|
|
215
|
+
})
|
|
216
|
+
process.argv = ['node', 'pi', '--no-context-files']
|
|
217
|
+
expect(runHook('BASE-PROMPT')).toEqual({ systemPrompt: 'BASE-PROMPT\n\nAPPEND-TEXT' })
|
|
218
|
+
// 守卫在读 global 文件之前:readdirSync 不应被调用(global 目录完全不被触碰)
|
|
219
|
+
expect(readdirSync).not.toHaveBeenCalled()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('argv 含 -nc 短形式 → 同样跳过 global 注入(与 argv-mirror 两种形式一致)', () => {
|
|
223
|
+
setupFs({
|
|
224
|
+
config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}',
|
|
225
|
+
globalEntries: ['AGENTS.md'],
|
|
226
|
+
globalFiles: { 'AGENTS.md': 'GLOBAL-CONTENT' },
|
|
227
|
+
})
|
|
228
|
+
process.argv = ['node', 'pi', '-nc']
|
|
229
|
+
expect(runHook('BASE-PROMPT')).toEqual({ systemPrompt: 'BASE-PROMPT\n\nAPPEND-TEXT' })
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe('global 候选文件选择(readGlobalAgentsFile)', () => {
|
|
234
|
+
it('候选序优先:AGENTS.MD 与 CLAUDE.md 并存 → AGENTS.MD 胜(候选列表顺序的第一个存在者)', () => {
|
|
235
|
+
setupFs({
|
|
236
|
+
globalEntries: ['AGENTS.MD', 'CLAUDE.md'],
|
|
237
|
+
globalFiles: { 'AGENTS.MD': 'FROM-AGENTS-UPPER', 'CLAUDE.md': 'FROM-CLAUDE' },
|
|
238
|
+
})
|
|
239
|
+
const result = runHook('BASE-PROMPT')
|
|
240
|
+
expect(result!.systemPrompt).toContain('FROM-AGENTS-UPPER')
|
|
241
|
+
expect(result!.systemPrompt).not.toContain('FROM-CLAUDE')
|
|
242
|
+
expect(result!.systemPrompt).toContain(path.join(GLOBAL_DIR, 'AGENTS.MD'))
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('首候选内容空白 → 跳过继续找下一候选(AGENTS.md 空白 + CLAUDE.md 有内容 → 注入 CLAUDE.md)', () => {
|
|
246
|
+
setupFs({
|
|
247
|
+
globalEntries: ['AGENTS.md', 'CLAUDE.md'],
|
|
248
|
+
globalFiles: { 'AGENTS.md': ' \n\t', 'CLAUDE.md': 'FROM-CLAUDE' },
|
|
249
|
+
})
|
|
250
|
+
const result = runHook('BASE-PROMPT')
|
|
251
|
+
expect(result!.systemPrompt).toContain('FROM-CLAUDE')
|
|
252
|
+
expect(result!.systemPrompt).toContain(path.join(GLOBAL_DIR, 'CLAUDE.md'))
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('global 目录不存在(readdirSync throw)→ 降级 null:不注入、不抛错', () => {
|
|
256
|
+
setupFs({ globalEntries: new Error('ENOENT: no such directory') })
|
|
257
|
+
expect(runHook('BASE-PROMPT')).toBeUndefined()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('fail-safe(外层 catch return undefined,永不阻断 agent loop)', () => {
|
|
262
|
+
it('handler 全程 throw(systemPrompt getter 抛错)→ return undefined + stderr 落盘可观测', () => {
|
|
263
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
264
|
+
const h = createHarness()
|
|
265
|
+
const event = { type: 'before_agent_start', prompt: 'hi' } as unknown as BeforeAgentStartEvent
|
|
266
|
+
Object.defineProperty(event, 'systemPrompt', {
|
|
267
|
+
get() {
|
|
268
|
+
throw new Error('getter boom')
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
expect(h.beforeAgentStart(event)).toBeUndefined()
|
|
272
|
+
expect(stderrSpy).toHaveBeenCalledWith(
|
|
273
|
+
expect.stringContaining('[xyz-system-prompt-extension] before_agent_start hook failed: Error: getter boom'),
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('stderr 写失败的终极兜底 → console.debug 吞掉,仍 return undefined', () => {
|
|
278
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => {
|
|
279
|
+
throw new Error('stderr gone')
|
|
280
|
+
})
|
|
281
|
+
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
282
|
+
const h = createHarness()
|
|
283
|
+
const event = { type: 'before_agent_start', prompt: 'hi' } as unknown as BeforeAgentStartEvent
|
|
284
|
+
Object.defineProperty(event, 'systemPrompt', {
|
|
285
|
+
get() {
|
|
286
|
+
throw new Error('getter boom')
|
|
287
|
+
},
|
|
288
|
+
})
|
|
289
|
+
expect(h.beforeAgentStart(event)).toBeUndefined()
|
|
290
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
291
|
+
expect.stringContaining('[xyz-system-prompt-extension] stderr write also failed:'),
|
|
292
|
+
expect.any(Error),
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('systemPrompt 非法类型(undefined)且无注入 → 旧 quirk 锚定:返回 {systemPrompt:""} 而非 undefined', () => {
|
|
297
|
+
// R3 复核锚定的返回值守卫 quirk(index.ts newPrompt === event.systemPrompt 比较):
|
|
298
|
+
// base 收敛 '','' !== undefined → 返回 {systemPrompt: ''}。与重构前行为一致(非回归)。
|
|
299
|
+
const h = createHarness()
|
|
300
|
+
const event = { type: 'before_agent_start', prompt: 'hi' } as unknown as BeforeAgentStartEvent
|
|
301
|
+
expect(h.beforeAgentStart(event)).toEqual({ systemPrompt: '' })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('systemPrompt 非法类型(undefined)但有 append 注入 → newPrompt = 空串 base + 分隔符 + append', () => {
|
|
305
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": "APPEND-TEXT"}}' })
|
|
306
|
+
const h = createHarness()
|
|
307
|
+
const event = { type: 'before_agent_start', prompt: 'hi' } as unknown as BeforeAgentStartEvent
|
|
308
|
+
// base 收敛 '',拼接形态固定为 '' + '\n\n' + append(分隔符保留,与合法 base 一致)
|
|
309
|
+
expect(h.beforeAgentStart(event)).toEqual({ systemPrompt: '\n\nAPPEND-TEXT' })
|
|
310
|
+
})
|
|
311
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt injection extension for Pi.
|
|
3
|
+
*
|
|
4
|
+
* Registers a `before_agent_start` hook that:
|
|
5
|
+
* 1. Reads <dataDir>/system-prompt.json every turn (never cached).
|
|
6
|
+
* 2. When `append.enabled === true` and `append.prompt` is non-blank,
|
|
7
|
+
* appends the user's text to the event's systemPrompt.
|
|
8
|
+
* 3. Reads the global instructions file `~/.agents/AGENTS.md` (candidates
|
|
9
|
+
* AGENTS.md / AGENTS.MD / CLAUDE.md / CLAUDE.MD — mirroring pi's native
|
|
10
|
+
* `loadContextFileFromDir`) every turn and appends it under a labeled
|
|
11
|
+
* header. Opt-in by file existence: no file → no injection. Skipped when
|
|
12
|
+
* pi was spawned with `--no-context-files` (consistent with pi's native
|
|
13
|
+
* context-file opt-out). `XYZ_GLOBAL_AGENTS_DIR` overrides the global
|
|
14
|
+
* directory (test hook / escape hatch).
|
|
15
|
+
*
|
|
16
|
+
* Injection order per turn: base prompt → global instructions → append config
|
|
17
|
+
* (the explicitly configured text wins last).
|
|
18
|
+
*
|
|
19
|
+
* Fail-safe: any error in the handler is swallowed and `undefined` is returned
|
|
20
|
+
* so the agent loop is never blocked.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
26
|
+
import type { ExtensionAPI, BeforeAgentStartEvent } from '@earendil-works/pi-coding-agent'
|
|
27
|
+
|
|
28
|
+
const CONFIG_FILE = 'system-prompt.json'
|
|
29
|
+
|
|
30
|
+
/** Global instruction candidates, mirroring pi's loadContextFileFromDir. */
|
|
31
|
+
const GLOBAL_AGENTS_CANDIDATES = ['AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the data directory from the environment.
|
|
35
|
+
*
|
|
36
|
+
* Priority:
|
|
37
|
+
* 1. `process.env.XYZ_AGENT_DATA_DIR` (explicit)
|
|
38
|
+
* 2. `path.resolve(process.env.PI_CODING_AGENT_DIR ?? '', '..', '..')`
|
|
39
|
+
* (PI_CODING_AGENT_DIR == <dataDir>/pi/agent, two levels up == dataDir)
|
|
40
|
+
*
|
|
41
|
+
* Re-read on every handler invocation so env changes between turns/sessions
|
|
42
|
+
* take effect without reloading the extension.
|
|
43
|
+
*/
|
|
44
|
+
function resolveDataDir(): string {
|
|
45
|
+
if (process.env.XYZ_AGENT_DATA_DIR) {
|
|
46
|
+
return process.env.XYZ_AGENT_DATA_DIR
|
|
47
|
+
}
|
|
48
|
+
return path.resolve(process.env.PI_CODING_AGENT_DIR ?? '', '..', '..')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the global agents directory.
|
|
53
|
+
*
|
|
54
|
+
* Priority:
|
|
55
|
+
* 1. `process.env.XYZ_GLOBAL_AGENTS_DIR` (explicit override; tests / escape
|
|
56
|
+
* hatch)
|
|
57
|
+
* 2. `~/.agents` (the user-global agents dir that also hosts skills/templates)
|
|
58
|
+
*
|
|
59
|
+
* Re-read on every handler invocation so env changes take effect.
|
|
60
|
+
*/
|
|
61
|
+
function resolveGlobalAgentsDir(): string {
|
|
62
|
+
if (process.env.XYZ_GLOBAL_AGENTS_DIR) {
|
|
63
|
+
return process.env.XYZ_GLOBAL_AGENTS_DIR
|
|
64
|
+
}
|
|
65
|
+
return path.join(homedir(), '.agents')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the global instructions file. First candidate that exists and is a
|
|
70
|
+
* regular file with non-blank content wins (mirrors pi's loadContextFileFromDir
|
|
71
|
+
* semantics). Returns { path, content } or null; never throws.
|
|
72
|
+
*/
|
|
73
|
+
function readGlobalAgentsFile(): { path: string; content: string } | null {
|
|
74
|
+
const dir = resolveGlobalAgentsDir()
|
|
75
|
+
// Match candidates against real directory entries (exact case) instead of
|
|
76
|
+
// existsSync-per-candidate: on case-insensitive filesystems (macOS APFS
|
|
77
|
+
// default) existsSync('AGENTS.md') would hit a file actually named
|
|
78
|
+
// AGENTS.MD, reporting an injected path that differs from the on-disk
|
|
79
|
+
// filename and shadowing the later exact-case candidate.
|
|
80
|
+
let entries: Set<string>
|
|
81
|
+
try {
|
|
82
|
+
entries = new Set(readdirSync(dir))
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
for (const name of GLOBAL_AGENTS_CANDIDATES) {
|
|
87
|
+
if (!entries.has(name)) continue
|
|
88
|
+
const filePath = path.join(dir, name)
|
|
89
|
+
try {
|
|
90
|
+
if (statSync(filePath).isFile()) {
|
|
91
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
92
|
+
if (content.trim()) {
|
|
93
|
+
return { path: filePath, content }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// best-effort:候选文件 stat/read 失败(如权限)→ 试下一个候选,never throw into the agent loop。
|
|
98
|
+
console.debug('[xyz-system-prompt-extension] candidate file read failed, trying next:', err)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read & parse the config file. Missing / malformed / partial → all-default.
|
|
106
|
+
* Returns the effective config object; never throws.
|
|
107
|
+
*/
|
|
108
|
+
function readConfig(dataDir: string): {
|
|
109
|
+
version: number
|
|
110
|
+
replace: { enabled: boolean; prompt: string }
|
|
111
|
+
append: { enabled: boolean; prompt: string }
|
|
112
|
+
} {
|
|
113
|
+
const parsed = readJsonIfValid(path.join(dataDir, CONFIG_FILE))
|
|
114
|
+
if (!parsed) {
|
|
115
|
+
return {
|
|
116
|
+
version: 1,
|
|
117
|
+
replace: { enabled: false, prompt: '' },
|
|
118
|
+
append: { enabled: false, prompt: '' },
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Merge defensively — every field has its own default.
|
|
122
|
+
// replace 字段仅防御性解析保持 config 结构完整,不参与本 hook 逻辑——
|
|
123
|
+
// replace 走 --system-prompt CLI(ADR-0038),hook 只处理 append。
|
|
124
|
+
return {
|
|
125
|
+
version: typeof parsed.version === 'number' ? parsed.version : 1,
|
|
126
|
+
replace: readSection(parsed.replace),
|
|
127
|
+
append: readSection(parsed.append),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Read a JSON file and return it as an object; missing / malformed / non-object → null. */
|
|
132
|
+
function readJsonIfValid(filePath: string): Record<string, unknown> | null {
|
|
133
|
+
try {
|
|
134
|
+
const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf-8'))
|
|
135
|
+
return isJsonObject(parsed) ? parsed : null
|
|
136
|
+
} catch {
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isJsonObject(v: unknown): v is Record<string, unknown> {
|
|
142
|
+
return !!v && typeof v === 'object'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Defensive field parsing for a `replace`/`append` config section. */
|
|
146
|
+
function readSection(raw: unknown): { enabled: boolean; prompt: string } {
|
|
147
|
+
const section = isJsonObject(raw) ? raw : {}
|
|
148
|
+
return {
|
|
149
|
+
enabled: section.enabled === true,
|
|
150
|
+
prompt: typeof section.prompt === 'string' ? section.prompt : '',
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* pi 是否以 --no-context-files / -nc 启动。用户显式退出 AGENTS.md / CLAUDE.md
|
|
156
|
+
* 发现时,全局文件不得从这条通路溜回来。pi CLI 把 -nc 视为 --no-context-files
|
|
157
|
+
* 的等价短形式(cli/args.ts),两种形式都必须命中守卫——与镜像侧
|
|
158
|
+
* (argv-mirror.ts 同样解析两种形式)保持一致。
|
|
159
|
+
*/
|
|
160
|
+
function contextFilesDisabled(): boolean {
|
|
161
|
+
return process.argv.includes('--no-context-files') || process.argv.includes('-nc')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Append the global instructions (~/.agents/AGENTS.md ...) under a labeled header. */
|
|
165
|
+
function withGlobalInstructions(prompt: string): string {
|
|
166
|
+
if (contextFilesDisabled()) return prompt
|
|
167
|
+
const global = readGlobalAgentsFile()
|
|
168
|
+
if (!global) return prompt
|
|
169
|
+
return prompt + '\n\n# Global instructions (' + global.path + ')\n\n' + global.content
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Read the append config and apply it to the prompt (empty append → unchanged). */
|
|
173
|
+
function withAppendPrompt(prompt: string): string {
|
|
174
|
+
const cfg = readConfig(resolveDataDir())
|
|
175
|
+
if (!cfg.append.enabled || !cfg.append.prompt.trim()) return prompt
|
|
176
|
+
return prompt + '\n\n' + cfg.append.prompt
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the injected system prompt. Injection order per turn:
|
|
181
|
+
* base prompt → global instructions → append config (the explicitly
|
|
182
|
+
* configured text wins last). Returns the new systemPrompt, or undefined
|
|
183
|
+
* when nothing changed.
|
|
184
|
+
*/
|
|
185
|
+
function buildSystemPrompt(event: BeforeAgentStartEvent): { systemPrompt: string } | undefined {
|
|
186
|
+
const basePrompt = typeof event.systemPrompt === 'string' ? event.systemPrompt : ''
|
|
187
|
+
const newPrompt = withAppendPrompt(withGlobalInstructions(basePrompt))
|
|
188
|
+
return newPrompt === event.systemPrompt ? undefined : { systemPrompt: newPrompt }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 落盘诊断(pi stderr 经 rpc-client 写入 logs/pi-*.jsonl),不泄露配置内容。 */
|
|
192
|
+
function logHookFailure(err: unknown): void {
|
|
193
|
+
try {
|
|
194
|
+
const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
|
195
|
+
process.stderr.write(`[xyz-system-prompt-extension] before_agent_start hook failed: ${msg}\n`)
|
|
196
|
+
} catch (nestedErr) {
|
|
197
|
+
// best-effort:stderr 写失败的终极兜底——console 内部吞错不会抛,仍不外泄到 agent loop。
|
|
198
|
+
console.debug('[xyz-system-prompt-extension] stderr write also failed:', nestedErr)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export default function (pi: ExtensionAPI): void {
|
|
203
|
+
pi.on('before_agent_start', (event: BeforeAgentStartEvent) => {
|
|
204
|
+
try {
|
|
205
|
+
return buildSystemPrompt(event)
|
|
206
|
+
} catch (err) {
|
|
207
|
+
// Never block the agent loop.
|
|
208
|
+
logHookFailure(err)
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
}
|