@zhushanwen/pi-system-prompt 1.0.2 → 1.1.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/package.json +4 -1
- package/src/__tests__/system-prompt.test.ts +61 -13
- package/src/index.ts +42 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-system-prompt",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "System prompt injection extension for Pi — reads config and appends to system prompt",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
"@vitest/coverage-v8": "^4.1.9",
|
|
32
32
|
"vitest": "^4.1.8"
|
|
33
33
|
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@zhushanwen/pi-extension-logger": "0.3.0"
|
|
36
|
+
},
|
|
34
37
|
"scripts": {
|
|
35
38
|
"typecheck": "npx tsc --noEmit",
|
|
36
39
|
"test": "vitest run"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* swap 注入顺序两行的 mutant 会被顺序用例 kill)
|
|
9
9
|
* - -nc / --no-context-files 守卫:global 注入跳过、append 不受影响
|
|
10
10
|
* - global 候选选择:候选序优先、空白内容跳过继续找、目录缺失降级
|
|
11
|
-
* - fail-safe:handler 全程 throw → return undefined +
|
|
11
|
+
* - fail-safe:handler 全程 throw → return undefined + logger.error 可观测;logger 自身抛错的
|
|
12
12
|
* 终极兜底 console.debug;systemPrompt 非法类型的旧 quirk 锚定
|
|
13
13
|
*
|
|
14
14
|
* mock 策略(参照 msg-id-mapper 测试模式):pi SDK import type 零运行时解析,
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
* 运行:cd extensions/taiji/system-prompt && npx vitest run
|
|
18
18
|
*/
|
|
19
19
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
20
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
21
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
22
|
+
}))
|
|
23
|
+
vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
24
|
+
getLogger: () => loggerMock,
|
|
25
|
+
createLogger: () => loggerMock,
|
|
26
|
+
}))
|
|
27
|
+
|
|
20
28
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
21
29
|
import path from 'node:path'
|
|
22
30
|
import createExtension from '../index'
|
|
@@ -66,7 +74,11 @@ interface FsSetup {
|
|
|
66
74
|
globalFiles?: Record<string, string | Error>
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
/** mtime 纪元:每次 setupFs 递增,模拟「文件被改写后 mtime 变化」。 */
|
|
78
|
+
let mtimeEpoch = 0
|
|
79
|
+
|
|
69
80
|
function setupFs(setup: FsSetup = {}): void {
|
|
81
|
+
mtimeEpoch += 1
|
|
70
82
|
const { config = new Error("ENOENT: no such file or directory, open '" + CONFIG_PATH + "'") } = setup
|
|
71
83
|
const { globalEntries = [], globalFiles = {} } = setup
|
|
72
84
|
vi.mocked(readdirSync).mockImplementation(() => {
|
|
@@ -74,10 +86,18 @@ function setupFs(setup: FsSetup = {}): void {
|
|
|
74
86
|
return globalEntries as unknown as string[]
|
|
75
87
|
})
|
|
76
88
|
vi.mocked(statSync).mockImplementation((p: unknown) => {
|
|
89
|
+
// config 文件可 stat(内容存在时)——cachedReadFileSync 先 stat 判 mtime 再读,
|
|
90
|
+
// config 缺失(Error)时 stat 同步 throw(等价 ENOENT)
|
|
91
|
+
if (String(p) === CONFIG_PATH) {
|
|
92
|
+
if (config instanceof Error) throw config
|
|
93
|
+
return { isFile: () => true, mtimeMs: mtimeEpoch } as unknown as ReturnType<typeof statSync>
|
|
94
|
+
}
|
|
77
95
|
const name = path.basename(String(p))
|
|
78
96
|
if (!(name in globalFiles)) throw new Error('ENOENT stat ' + String(p))
|
|
79
97
|
if (globalFiles[name] instanceof Error) throw globalFiles[name]
|
|
80
|
-
|
|
98
|
+
// mtimeMs 模拟真实 mtime:每次 setupFs 递增一次纪元——同一 setup 内稳定(缓存命中),
|
|
99
|
+
// 重新 setupFs(等价改文件)后变化(缓存失效重读),与 cachedReadFileSync 判变语义对齐。
|
|
100
|
+
return { isFile: () => true, mtimeMs: mtimeEpoch } as unknown as ReturnType<typeof statSync>
|
|
81
101
|
})
|
|
82
102
|
vi.mocked(readFileSync).mockImplementation((p: unknown) => {
|
|
83
103
|
const fp = String(p)
|
|
@@ -259,8 +279,7 @@ describe('global 候选文件选择(readGlobalAgentsFile)', () => {
|
|
|
259
279
|
})
|
|
260
280
|
|
|
261
281
|
describe('fail-safe(外层 catch return undefined,永不阻断 agent loop)', () => {
|
|
262
|
-
it('handler 全程 throw(systemPrompt getter 抛错)→ return undefined +
|
|
263
|
-
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
282
|
+
it('handler 全程 throw(systemPrompt getter 抛错)→ return undefined + logger.error 落盘可观测', () => {
|
|
264
283
|
const h = createHarness()
|
|
265
284
|
const event = { type: 'before_agent_start', prompt: 'hi' } as unknown as BeforeAgentStartEvent
|
|
266
285
|
Object.defineProperty(event, 'systemPrompt', {
|
|
@@ -269,16 +288,16 @@ describe('fail-safe(外层 catch return undefined,永不阻断 agent loop)
|
|
|
269
288
|
},
|
|
270
289
|
})
|
|
271
290
|
expect(h.beforeAgentStart(event)).toBeUndefined()
|
|
272
|
-
expect(
|
|
273
|
-
|
|
291
|
+
expect(loggerMock.error).toHaveBeenCalledWith(
|
|
292
|
+
'before_agent_start hook failed: Error: getter boom',
|
|
274
293
|
)
|
|
275
294
|
})
|
|
276
295
|
|
|
277
|
-
it('
|
|
278
|
-
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() =>
|
|
279
|
-
|
|
296
|
+
it('logger 自身抛错的终极兜底 → stderr 兜底,仍 return undefined', () => {
|
|
297
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
298
|
+
loggerMock.error.mockImplementation(() => {
|
|
299
|
+
throw new Error('logger gone')
|
|
280
300
|
})
|
|
281
|
-
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
282
301
|
const h = createHarness()
|
|
283
302
|
const event = { type: 'before_agent_start', prompt: 'hi' } as unknown as BeforeAgentStartEvent
|
|
284
303
|
Object.defineProperty(event, 'systemPrompt', {
|
|
@@ -287,9 +306,8 @@ describe('fail-safe(外层 catch return undefined,永不阻断 agent loop)
|
|
|
287
306
|
},
|
|
288
307
|
})
|
|
289
308
|
expect(h.beforeAgentStart(event)).toBeUndefined()
|
|
290
|
-
expect(
|
|
291
|
-
expect.stringContaining('
|
|
292
|
-
expect.any(Error),
|
|
309
|
+
expect(stderrSpy).toHaveBeenCalledWith(
|
|
310
|
+
expect.stringContaining('logHookFailure also failed'),
|
|
293
311
|
)
|
|
294
312
|
})
|
|
295
313
|
|
|
@@ -309,3 +327,33 @@ describe('fail-safe(外层 catch return undefined,永不阻断 agent loop)
|
|
|
309
327
|
expect(h.beforeAgentStart(event)).toEqual({ systemPrompt: '\n\nAPPEND-TEXT' })
|
|
310
328
|
})
|
|
311
329
|
})
|
|
330
|
+
|
|
331
|
+
describe('cachedReadFileSync(mtime 级内容缓存,KV-cache 稳定性改造)', () => {
|
|
332
|
+
it('文件未变时二次 hook 不再重复 readFileSync(缓存命中)', () => {
|
|
333
|
+
setupFs({
|
|
334
|
+
config: '{"append": {"enabled": true, "prompt": "P1"}}',
|
|
335
|
+
globalEntries: ['AGENTS.md'],
|
|
336
|
+
globalFiles: { 'AGENTS.md': '# GLOBAL' },
|
|
337
|
+
})
|
|
338
|
+
const r1 = runHook('base')
|
|
339
|
+
const reads1 = vi.mocked(readFileSync).mock.calls.length
|
|
340
|
+
const r2 = runHook('base')
|
|
341
|
+
expect(r2).toEqual(r1) // 两次注入结果逐字节一致
|
|
342
|
+
expect(vi.mocked(readFileSync).mock.calls.length).toBe(reads1) // 第二次全命中缓存,零重读
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('文件改写(mtime 变)后下一轮 hook 读到新内容(变更即生效语义保留)', () => {
|
|
346
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": "P1"}}' })
|
|
347
|
+
expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP1' })
|
|
348
|
+
// 模拟用户改写 append.prompt(setupFs 递增 mtime 纪元)
|
|
349
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": "P2"}}' })
|
|
350
|
+
expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP2' })
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('文件删除(stat throw)后缓存驱逐,注入降级为无 append', () => {
|
|
354
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": "P1"}}' })
|
|
355
|
+
expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP1' })
|
|
356
|
+
setupFs() // config 恢复默认 ENOENT
|
|
357
|
+
expect(runHook('base')).toBeUndefined()
|
|
358
|
+
})
|
|
359
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* System prompt injection extension for Pi.
|
|
3
3
|
*
|
|
4
4
|
* Registers a `before_agent_start` hook that:
|
|
5
|
-
* 1. Reads <dataDir>/system-prompt.json every turn (
|
|
5
|
+
* 1. Reads <dataDir>/system-prompt.json every turn (mtime-cached, see
|
|
6
|
+
* `cachedReadFileSync`).
|
|
6
7
|
* 2. When `append.enabled === true` and `append.prompt` is non-blank,
|
|
7
8
|
* appends the user's text to the event's systemPrompt.
|
|
8
9
|
* 3. Reads the global instructions file `~/.agents/AGENTS.md` (candidates
|
|
@@ -24,9 +25,37 @@ import path from 'node:path'
|
|
|
24
25
|
import { homedir } from 'node:os'
|
|
25
26
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
26
27
|
import type { ExtensionAPI, BeforeAgentStartEvent } from '@earendil-works/pi-coding-agent'
|
|
28
|
+
import { getLogger } from '@zhushanwen/pi-extension-logger'
|
|
29
|
+
|
|
30
|
+
const logger = getLogger('xyz-system-prompt-extension')
|
|
27
31
|
|
|
28
32
|
const CONFIG_FILE = 'system-prompt.json'
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* mtime 级文件内容缓存(KV-cache 稳定性改造):每 turn 仍 stat 判变(文件被编辑后
|
|
36
|
+
* 下一轮即读到新内容,语义与逐 turn 重读一致),但 mtime 未变时跳过 readFileSync——
|
|
37
|
+
* 注入文本进每 turn system prompt,读盘路径上不引入额外开销。进程级缓存,per-process
|
|
38
|
+
* = per-session,生命周期对齐。stat/read 失败 → 驱逐条目返回 null。
|
|
39
|
+
*
|
|
40
|
+
* @data-owner 文件本身(mtime+size 判变读缓存,非派生权威,无第二写入者)
|
|
41
|
+
*/
|
|
42
|
+
const fileContentCache = new Map<string, { mtimeMs: number; size: number; content: string }>()
|
|
43
|
+
|
|
44
|
+
function cachedReadFileSync(filePath: string): string | null {
|
|
45
|
+
try {
|
|
46
|
+
const stat = statSync(filePath)
|
|
47
|
+
const entry = fileContentCache.get(filePath)
|
|
48
|
+
// 双键判变:mtimeMs 相同粒度内被外部编辑器/脚本覆写且长度变化时 size 仍能命中
|
|
49
|
+
if (entry && entry.mtimeMs === stat.mtimeMs && entry.size === stat.size) return entry.content
|
|
50
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
51
|
+
fileContentCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, content })
|
|
52
|
+
return content
|
|
53
|
+
} catch {
|
|
54
|
+
fileContentCache.delete(filePath)
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
/** Global instruction candidates, mirroring pi's loadContextFileFromDir. */
|
|
31
60
|
const GLOBAL_AGENTS_CANDIDATES = ['AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
|
|
32
61
|
|
|
@@ -88,14 +117,14 @@ function readGlobalAgentsFile(): { path: string; content: string } | null {
|
|
|
88
117
|
const filePath = path.join(dir, name)
|
|
89
118
|
try {
|
|
90
119
|
if (statSync(filePath).isFile()) {
|
|
91
|
-
const content =
|
|
92
|
-
if (content.trim()) {
|
|
120
|
+
const content = cachedReadFileSync(filePath)
|
|
121
|
+
if (content !== null && content.trim()) {
|
|
93
122
|
return { path: filePath, content }
|
|
94
123
|
}
|
|
95
124
|
}
|
|
96
125
|
} catch (err) {
|
|
97
126
|
// best-effort:候选文件 stat/read 失败(如权限)→ 试下一个候选,never throw into the agent loop。
|
|
98
|
-
|
|
127
|
+
logger.debug('candidate file read failed, trying next', { detail: String(err) })
|
|
99
128
|
}
|
|
100
129
|
}
|
|
101
130
|
return null
|
|
@@ -131,7 +160,9 @@ function readConfig(dataDir: string): {
|
|
|
131
160
|
/** Read a JSON file and return it as an object; missing / malformed / non-object → null. */
|
|
132
161
|
function readJsonIfValid(filePath: string): Record<string, unknown> | null {
|
|
133
162
|
try {
|
|
134
|
-
const
|
|
163
|
+
const raw = cachedReadFileSync(filePath)
|
|
164
|
+
if (raw === null) return null
|
|
165
|
+
const parsed: unknown = JSON.parse(raw)
|
|
135
166
|
return isJsonObject(parsed) ? parsed : null
|
|
136
167
|
} catch {
|
|
137
168
|
return null
|
|
@@ -192,10 +223,14 @@ function buildSystemPrompt(event: BeforeAgentStartEvent): { systemPrompt: string
|
|
|
192
223
|
function logHookFailure(err: unknown): void {
|
|
193
224
|
try {
|
|
194
225
|
const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
|
195
|
-
|
|
226
|
+
logger.error(`before_agent_start hook failed: ${msg}`)
|
|
196
227
|
} catch (nestedErr) {
|
|
197
228
|
// best-effort:stderr 写失败的终极兜底——console 内部吞错不会抛,仍不外泄到 agent loop。
|
|
198
|
-
|
|
229
|
+
try {
|
|
230
|
+
process.stderr.write(`[xyz-system-prompt-extension] logHookFailure also failed: ${String(nestedErr)}\n`)
|
|
231
|
+
} catch {
|
|
232
|
+
/* 完全静默:两层兑底都失败时无处可写 */
|
|
233
|
+
}
|
|
199
234
|
}
|
|
200
235
|
}
|
|
201
236
|
|