@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-system-prompt",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
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",
@@ -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
- return { isFile: () => true } as unknown as ReturnType<typeof statSync>
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 (never cached).
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 = readFileSync(filePath, 'utf-8')
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 parsed: unknown = JSON.parse(readFileSync(filePath, 'utf-8'))
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