@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 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
+ }