@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-system-prompt",
3
- "version": "1.0.2",
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 + stderr 可观测;stderr 写失败的
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
- return { isFile: () => true } as unknown as ReturnType<typeof statSync>
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 + stderr 落盘可观测', () => {
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(stderrSpy).toHaveBeenCalledWith(
273
- expect.stringContaining('[xyz-system-prompt-extension] before_agent_start hook failed: Error: getter boom'),
291
+ expect(loggerMock.error).toHaveBeenCalledWith(
292
+ 'before_agent_start hook failed: Error: getter boom',
274
293
  )
275
294
  })
276
295
 
277
- it('stderr 写失败的终极兜底console.debug 吞掉,仍 return undefined', () => {
278
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => {
279
- throw new Error('stderr gone')
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(debugSpy).toHaveBeenCalledWith(
291
- expect.stringContaining('[xyz-system-prompt-extension] stderr write also failed:'),
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 (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
@@ -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 = readFileSync(filePath, 'utf-8')
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
- console.debug('[xyz-system-prompt-extension] candidate file read failed, trying next:', err)
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 parsed: unknown = JSON.parse(readFileSync(filePath, 'utf-8'))
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
- process.stderr.write(`[xyz-system-prompt-extension] before_agent_start hook failed: ${msg}\n`)
226
+ logger.error(`before_agent_start hook failed: ${msg}`)
196
227
  } catch (nestedErr) {
197
228
  // best-effort:stderr 写失败的终极兜底——console 内部吞错不会抛,仍不外泄到 agent loop。
198
- console.debug('[xyz-system-prompt-extension] stderr write also failed:', nestedErr)
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