@zhushanwen/pi-system-prompt 1.0.2 → 1.1.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 +43 -1
- package/src/index.ts +32 -4
package/package.json
CHANGED
|
@@ -66,7 +66,11 @@ interface FsSetup {
|
|
|
66
66
|
globalFiles?: Record<string, string | Error>
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** mtime 纪元:每次 setupFs 递增,模拟「文件被改写后 mtime 变化」。 */
|
|
70
|
+
let mtimeEpoch = 0
|
|
71
|
+
|
|
69
72
|
function setupFs(setup: FsSetup = {}): void {
|
|
73
|
+
mtimeEpoch += 1
|
|
70
74
|
const { config = new Error("ENOENT: no such file or directory, open '" + CONFIG_PATH + "'") } = setup
|
|
71
75
|
const { globalEntries = [], globalFiles = {} } = setup
|
|
72
76
|
vi.mocked(readdirSync).mockImplementation(() => {
|
|
@@ -74,10 +78,18 @@ function setupFs(setup: FsSetup = {}): void {
|
|
|
74
78
|
return globalEntries as unknown as string[]
|
|
75
79
|
})
|
|
76
80
|
vi.mocked(statSync).mockImplementation((p: unknown) => {
|
|
81
|
+
// config 文件可 stat(内容存在时)——cachedReadFileSync 先 stat 判 mtime 再读,
|
|
82
|
+
// config 缺失(Error)时 stat 同步 throw(等价 ENOENT)
|
|
83
|
+
if (String(p) === CONFIG_PATH) {
|
|
84
|
+
if (config instanceof Error) throw config
|
|
85
|
+
return { isFile: () => true, mtimeMs: mtimeEpoch } as unknown as ReturnType<typeof statSync>
|
|
86
|
+
}
|
|
77
87
|
const name = path.basename(String(p))
|
|
78
88
|
if (!(name in globalFiles)) throw new Error('ENOENT stat ' + String(p))
|
|
79
89
|
if (globalFiles[name] instanceof Error) throw globalFiles[name]
|
|
80
|
-
|
|
90
|
+
// mtimeMs 模拟真实 mtime:每次 setupFs 递增一次纪元——同一 setup 内稳定(缓存命中),
|
|
91
|
+
// 重新 setupFs(等价改文件)后变化(缓存失效重读),与 cachedReadFileSync 判变语义对齐。
|
|
92
|
+
return { isFile: () => true, mtimeMs: mtimeEpoch } as unknown as ReturnType<typeof statSync>
|
|
81
93
|
})
|
|
82
94
|
vi.mocked(readFileSync).mockImplementation((p: unknown) => {
|
|
83
95
|
const fp = String(p)
|
|
@@ -309,3 +321,33 @@ describe('fail-safe(外层 catch return undefined,永不阻断 agent loop)
|
|
|
309
321
|
expect(h.beforeAgentStart(event)).toEqual({ systemPrompt: '\n\nAPPEND-TEXT' })
|
|
310
322
|
})
|
|
311
323
|
})
|
|
324
|
+
|
|
325
|
+
describe('cachedReadFileSync(mtime 级内容缓存,KV-cache 稳定性改造)', () => {
|
|
326
|
+
it('文件未变时二次 hook 不再重复 readFileSync(缓存命中)', () => {
|
|
327
|
+
setupFs({
|
|
328
|
+
config: '{"append": {"enabled": true, "prompt": "P1"}}',
|
|
329
|
+
globalEntries: ['AGENTS.md'],
|
|
330
|
+
globalFiles: { 'AGENTS.md': '# GLOBAL' },
|
|
331
|
+
})
|
|
332
|
+
const r1 = runHook('base')
|
|
333
|
+
const reads1 = vi.mocked(readFileSync).mock.calls.length
|
|
334
|
+
const r2 = runHook('base')
|
|
335
|
+
expect(r2).toEqual(r1) // 两次注入结果逐字节一致
|
|
336
|
+
expect(vi.mocked(readFileSync).mock.calls.length).toBe(reads1) // 第二次全命中缓存,零重读
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('文件改写(mtime 变)后下一轮 hook 读到新内容(变更即生效语义保留)', () => {
|
|
340
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": "P1"}}' })
|
|
341
|
+
expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP1' })
|
|
342
|
+
// 模拟用户改写 append.prompt(setupFs 递增 mtime 纪元)
|
|
343
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": "P2"}}' })
|
|
344
|
+
expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP2' })
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('文件删除(stat throw)后缓存驱逐,注入降级为无 append', () => {
|
|
348
|
+
setupFs({ config: '{"append": {"enabled": true, "prompt": "P1"}}' })
|
|
349
|
+
expect(runHook('base')).toEqual({ systemPrompt: 'base\n\nP1' })
|
|
350
|
+
setupFs() // config 恢复默认 ENOENT
|
|
351
|
+
expect(runHook('base')).toBeUndefined()
|
|
352
|
+
})
|
|
353
|
+
})
|
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
|
|
@@ -27,6 +28,31 @@ import type { ExtensionAPI, BeforeAgentStartEvent } from '@earendil-works/pi-cod
|
|
|
27
28
|
|
|
28
29
|
const CONFIG_FILE = 'system-prompt.json'
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* mtime 级文件内容缓存(KV-cache 稳定性改造):每 turn 仍 stat 判变(文件被编辑后
|
|
33
|
+
* 下一轮即读到新内容,语义与逐 turn 重读一致),但 mtime 未变时跳过 readFileSync——
|
|
34
|
+
* 注入文本进每 turn system prompt,读盘路径上不引入额外开销。进程级缓存,per-process
|
|
35
|
+
* = per-session,生命周期对齐。stat/read 失败 → 驱逐条目返回 null。
|
|
36
|
+
*
|
|
37
|
+
* @data-owner 文件本身(mtime+size 判变读缓存,非派生权威,无第二写入者)
|
|
38
|
+
*/
|
|
39
|
+
const fileContentCache = new Map<string, { mtimeMs: number; size: number; content: string }>()
|
|
40
|
+
|
|
41
|
+
function cachedReadFileSync(filePath: string): string | null {
|
|
42
|
+
try {
|
|
43
|
+
const stat = statSync(filePath)
|
|
44
|
+
const entry = fileContentCache.get(filePath)
|
|
45
|
+
// 双键判变:mtimeMs 相同粒度内被外部编辑器/脚本覆写且长度变化时 size 仍能命中
|
|
46
|
+
if (entry && entry.mtimeMs === stat.mtimeMs && entry.size === stat.size) return entry.content
|
|
47
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
48
|
+
fileContentCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, content })
|
|
49
|
+
return content
|
|
50
|
+
} catch {
|
|
51
|
+
fileContentCache.delete(filePath)
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
/** Global instruction candidates, mirroring pi's loadContextFileFromDir. */
|
|
31
57
|
const GLOBAL_AGENTS_CANDIDATES = ['AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']
|
|
32
58
|
|
|
@@ -88,8 +114,8 @@ function readGlobalAgentsFile(): { path: string; content: string } | null {
|
|
|
88
114
|
const filePath = path.join(dir, name)
|
|
89
115
|
try {
|
|
90
116
|
if (statSync(filePath).isFile()) {
|
|
91
|
-
const content =
|
|
92
|
-
if (content.trim()) {
|
|
117
|
+
const content = cachedReadFileSync(filePath)
|
|
118
|
+
if (content !== null && content.trim()) {
|
|
93
119
|
return { path: filePath, content }
|
|
94
120
|
}
|
|
95
121
|
}
|
|
@@ -131,7 +157,9 @@ function readConfig(dataDir: string): {
|
|
|
131
157
|
/** Read a JSON file and return it as an object; missing / malformed / non-object → null. */
|
|
132
158
|
function readJsonIfValid(filePath: string): Record<string, unknown> | null {
|
|
133
159
|
try {
|
|
134
|
-
const
|
|
160
|
+
const raw = cachedReadFileSync(filePath)
|
|
161
|
+
if (raw === null) return null
|
|
162
|
+
const parsed: unknown = JSON.parse(raw)
|
|
135
163
|
return isJsonObject(parsed) ? parsed : null
|
|
136
164
|
} catch {
|
|
137
165
|
return null
|