decorated-pi 0.5.4 → 0.6.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.
Files changed (68) hide show
  1. package/.codegraph/daemon.pid +6 -0
  2. package/AGENTS.md +92 -0
  3. package/README.md +53 -64
  4. package/commands/dp-model.ts +23 -0
  5. package/commands/dp-settings.ts +23 -0
  6. package/commands/mcp-status.ts +62 -0
  7. package/commands/retry.ts +19 -0
  8. package/commands/usage.ts +544 -0
  9. package/hooks/compaction.ts +204 -0
  10. package/hooks/externalize.ts +70 -0
  11. package/hooks/image-vision.ts +132 -0
  12. package/hooks/inject-agents-md.ts +164 -0
  13. package/hooks/mcp.ts +340 -0
  14. package/hooks/normalize-codeblocks.ts +88 -0
  15. package/hooks/pi-tool-filter.ts +28 -0
  16. package/{extensions/safety → hooks/redact}/types.ts +1 -8
  17. package/hooks/redact.ts +104 -0
  18. package/{extensions → hooks}/rtk.ts +92 -115
  19. package/hooks/session-title.ts +54 -0
  20. package/hooks/skeleton.ts +212 -0
  21. package/hooks/smart-at.ts +318 -0
  22. package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
  23. package/{extensions → hooks}/wakatime.ts +120 -122
  24. package/index.ts +155 -1
  25. package/package.json +5 -4
  26. package/{extensions/settings.ts → settings.ts} +32 -0
  27. package/{extensions → tools}/lsp/client.ts +0 -25
  28. package/{extensions → tools}/lsp/format.ts +2 -99
  29. package/{extensions → tools}/lsp/index.ts +1 -1
  30. package/{extensions → tools}/lsp/servers.ts +1 -1
  31. package/{extensions → tools}/lsp/tools.ts +1 -66
  32. package/{extensions → tools}/lsp/types.ts +0 -11
  33. package/tools/mcp/builtin/codegraph.ts +45 -0
  34. package/tools/mcp/builtin/context7.ts +10 -0
  35. package/tools/mcp/builtin/exa.ts +10 -0
  36. package/tools/mcp/builtin/index.ts +19 -0
  37. package/tools/mcp/cache.ts +124 -0
  38. package/{extensions → tools}/mcp/client.ts +2 -2
  39. package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
  40. package/tools/mcp/index.ts +44 -0
  41. package/tools/mcp/tool-definition.ts +112 -0
  42. package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
  43. package/{extensions/io.ts → tools/patch/index.ts} +29 -205
  44. package/tsconfig.json +10 -1
  45. package/ui/mcp-status.ts +202 -0
  46. package/ui/model-picker.ts +162 -0
  47. package/ui/module-settings.ts +83 -0
  48. package/ui/usage.ts +396 -0
  49. package/extensions/index.ts +0 -154
  50. package/extensions/io-tool-output.ts +0 -33
  51. package/extensions/mcp/index.ts +0 -435
  52. package/extensions/model-integration.ts +0 -531
  53. package/extensions/providers/ark-coding.ts +0 -75
  54. package/extensions/providers/index.ts +0 -9
  55. package/extensions/providers/ollama-cloud.ts +0 -101
  56. package/extensions/providers/qianfan-coding.ts +0 -71
  57. package/extensions/safety/index.ts +0 -102
  58. package/extensions/session-title.ts +0 -40
  59. package/extensions/slash.ts +0 -458
  60. package/extensions/smart-at.ts +0 -481
  61. package/extensions/subdir-agents.ts +0 -151
  62. /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
  63. /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
  64. /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
  65. /package/{extensions → tools}/lsp/env.ts +0 -0
  66. /package/{extensions → tools}/lsp/manager.ts +0 -0
  67. /package/{extensions → tools}/lsp/prompt.ts +0 -0
  68. /package/{extensions → tools}/lsp/protocol.ts +0 -0
@@ -1,12 +1,8 @@
1
1
  /**
2
- * WakaTime — coding activity heartbeats for Pi sessions
2
+ * wakatime — coding activity heartbeats.
3
3
  *
4
- * Reads API key from ~/.wakatime.cfg and sends heartbeats to WakaTime using a
5
- * small runtime state machine:
6
- * - before_agent_start → mark app activity
7
- * - tool_result → switch active entity/category based on real work
8
- * - agent_end → stop immediate activity, keepalive handles continuity
9
- * - keepalive timer → extend continuous activity while not idle
4
+ * Reads API key from ~/.wakatime.cfg and sends heartbeats via wakatime-cli.
5
+ * Owns its own timer state and the "active" entity.
10
6
  */
11
7
 
12
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -15,12 +11,11 @@ import * as fs from "node:fs";
15
11
  import * as os from "node:os";
16
12
  import * as path from "node:path";
17
13
  import { fileURLToPath } from "node:url";
14
+ import type { Module, Skeleton } from "./skeleton.js";
18
15
 
19
16
  const MACHINE_NAME = os.hostname();
20
17
  const PACKAGE_VERSION = readPackageVersion();
21
18
 
22
- // ─── Config ────────────────────────────────────────────────────────────────
23
-
24
19
  const WAKATIME_CFG = path.join(os.homedir(), ".wakatime.cfg");
25
20
  const WAKATIME_CLI_FALLBACK = path.join(os.homedir(), ".wakatime", "wakatime-cli");
26
21
  const API_URL = "https://api.wakatime.com/api/v1";
@@ -31,7 +26,7 @@ const TIMER_TICK_MS = 15 * 1000;
31
26
  function readPackageVersion(): string {
32
27
  try {
33
28
  const here = path.dirname(fileURLToPath(import.meta.url));
34
- const pkgPath = path.join(here, "..", "package.json");
29
+ const pkgPath = path.join(here, "package.json");
35
30
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
36
31
  return pkg.version?.trim() || "unknown";
37
32
  } catch {
@@ -49,11 +44,7 @@ export function readWakatimeCfgApiKey(configPath = WAKATIME_CFG): string | undef
49
44
  }
50
45
  }
51
46
 
52
- function getApiKey(): string | undefined {
53
- return readWakatimeCfgApiKey();
54
- }
55
-
56
- // ─── Heartbeat types ───────────────────────────────────────────────────────
47
+ // ─── Heartbeat types ──────────────────────────────────────────────────────
57
48
 
58
49
  interface Heartbeat {
59
50
  entity: string;
@@ -81,7 +72,7 @@ let timer: ReturnType<typeof setInterval> | null = null;
81
72
  let terminalInputUnsub: (() => void) | null = null;
82
73
  let cachedWakatimeCliPath: string | null | undefined;
83
74
 
84
- // ─── Helpers ───────────────────────────────────────────────────────────────
75
+ // ─── Helpers ──────────────────────────────────────────────────────────────
85
76
 
86
77
  function extToLanguage(filePath: string): string | undefined {
87
78
  const ext = path.extname(filePath).toLowerCase();
@@ -122,12 +113,8 @@ function countLines(absPath: string): number | undefined {
122
113
  export function classifyBash(command: string | undefined): Heartbeat["category"] {
123
114
  const cmd = String(command ?? "").trim();
124
115
  if (!cmd) return "ai coding";
125
- if (/\b(make|cmake|ninja|npm run build|pnpm build|yarn build|cargo build|go build)\b/i.test(cmd)) {
126
- return "building";
127
- }
128
- if (/\b(pytest|vitest|jest|npm test|pnpm test|yarn test|go test|ctest|cargo test)\b/i.test(cmd)) {
129
- return "running tests";
130
- }
116
+ if (/\b(make|cmake|ninja|npm run build|pnpm build|yarn build|cargo build|go build)\b/i.test(cmd)) return "building";
117
+ if (/\b(pytest|vitest|jest|npm test|pnpm test|yarn test|go test|ctest|cargo test)\b/i.test(cmd)) return "running tests";
131
118
  return "ai coding";
132
119
  }
133
120
 
@@ -156,7 +143,6 @@ export function findWakatimeCli(options: {
156
143
  fallbackPath?: string;
157
144
  } = {}): string | null {
158
145
  if (cachedWakatimeCliPath !== undefined) return cachedWakatimeCliPath;
159
-
160
146
  const probePath = options.probePath ?? findWakatimeCliOnPath;
161
147
  const exists = options.exists ?? fs.existsSync;
162
148
  const fromPath = probePath();
@@ -164,28 +150,17 @@ export function findWakatimeCli(options: {
164
150
  cachedWakatimeCliPath = path.resolve(fromPath);
165
151
  return cachedWakatimeCliPath;
166
152
  }
167
-
168
153
  const fallback = path.resolve(options.fallbackPath ?? WAKATIME_CLI_FALLBACK);
169
154
  cachedWakatimeCliPath = exists(fallback) ? fallback : null;
170
155
  return cachedWakatimeCliPath;
171
156
  }
172
157
 
173
- export function buildCliArgs(
174
- hb: Heartbeat,
175
- apiKey: string,
176
- cwd?: string,
177
- plugin = buildPluginString(),
178
- ): string[] {
158
+ export function buildCliArgs(hb: Heartbeat, apiKey: string, cwd?: string, plugin = buildPluginString()): string[] {
179
159
  const args = [
180
- "--entity", hb.entity,
181
- "--entity-type", hb.type,
182
- "--category", hb.category,
183
- "--plugin", plugin,
184
- "--key", apiKey,
185
- "--time", String(hb.time),
160
+ "--entity", hb.entity, "--entity-type", hb.type, "--category", hb.category,
161
+ "--plugin", plugin, "--key", apiKey, "--time", String(hb.time),
186
162
  "--hostname", MACHINE_NAME,
187
163
  ];
188
-
189
164
  const shouldSendProjectFolder = hb.type === "app" || !!hb.project || typeof hb.project_root_count === "number";
190
165
  if (cwd && shouldSendProjectFolder) args.push("--project-folder", cwd);
191
166
  if (hb.project) args.push("--project", hb.project);
@@ -197,55 +172,32 @@ export function buildCliArgs(
197
172
 
198
173
  function sendHeartbeatViaCli(hb: Heartbeat, apiKey: string, cliPath: string, cwd?: string): void {
199
174
  const args = buildCliArgs(hb, apiKey, cwd);
200
- execFile(cliPath, args, {
201
- timeout: 10_000,
202
- windowsHide: true,
203
- }, () => {
204
- // Silent: activity tracking must never interrupt the agent.
205
- });
175
+ execFile(cliPath, args, { timeout: 10_000, windowsHide: true }, () => {});
206
176
  }
207
177
 
208
178
  export function heartbeatChanged(a: Omit<Heartbeat, "time">, b: Omit<Heartbeat, "time">): boolean {
209
- return (
210
- a.entity !== b.entity ||
211
- a.type !== b.type ||
212
- a.category !== b.category ||
213
- a.project !== b.project ||
214
- a.project_root_count !== b.project_root_count ||
215
- a.language !== b.language
216
- );
179
+ return a.entity !== b.entity || a.type !== b.type || a.category !== b.category
180
+ || a.project !== b.project || a.project_root_count !== b.project_root_count
181
+ || a.language !== b.language;
217
182
  }
218
183
 
219
- function switchActive(
220
- next: Omit<Heartbeat, "time">,
221
- sendHeartbeat: HeartbeatSender,
222
- options: { immediate?: boolean; isWrite?: boolean; cwd?: string } = {},
223
- ): void {
184
+ function switchActive(next: Omit<Heartbeat, "time">, sendHeartbeat: HeartbeatSender, options: { immediate?: boolean; isWrite?: boolean; cwd?: string } = {}) {
224
185
  const now = Date.now();
225
186
  const changed = !active || heartbeatChanged(active.heartbeat, next);
226
-
227
- active = {
228
- heartbeat: next,
229
- cwd: options.cwd,
230
- lastActivityAt: now,
231
- lastHeartbeatAt: active?.lastHeartbeatAt ?? 0,
232
- };
233
-
187
+ active = { heartbeat: next, cwd: options.cwd, lastActivityAt: now, lastHeartbeatAt: active?.lastHeartbeatAt ?? 0 };
234
188
  if (changed || options.immediate || options.isWrite) {
235
189
  sendHeartbeat({ ...next, time: now / 1000, is_write: options.isWrite }, options.cwd);
236
190
  active.lastHeartbeatAt = now;
237
191
  }
238
192
  }
239
193
 
240
- function sendOneShot(hb: Omit<Heartbeat, "time">, sendHeartbeat: HeartbeatSender, isWrite?: boolean, cwd?: string): void {
194
+ function sendOneShot(hb: Omit<Heartbeat, "time">, sendHeartbeat: HeartbeatSender, isWrite?: boolean, cwd?: string) {
241
195
  sendHeartbeat({ ...hb, time: Date.now() / 1000, is_write: isWrite }, cwd);
242
196
  }
243
197
 
244
- function touchActivity(): void {
245
- if (active) active.lastActivityAt = Date.now();
246
- }
198
+ function touchActivity() { if (active) active.lastActivityAt = Date.now(); }
247
199
 
248
- function ensureTimer(sendHeartbeat: HeartbeatSender): void {
200
+ function ensureTimer(sendHeartbeat: HeartbeatSender) {
249
201
  if (timer) return;
250
202
  timer = setInterval(() => {
251
203
  if (!active) return;
@@ -257,38 +209,22 @@ function ensureTimer(sendHeartbeat: HeartbeatSender): void {
257
209
  }, TIMER_TICK_MS);
258
210
  }
259
211
 
260
- function clearTimer(): void {
212
+ function clearTimer() {
261
213
  if (timer) clearInterval(timer);
262
214
  timer = null;
263
215
  }
264
216
 
265
217
  export function buildAppHeartbeat(cwd: string, category: Heartbeat["category"] = "ai coding"): Omit<Heartbeat, "time"> {
266
218
  const root = path.resolve(cwd);
267
- return {
268
- entity: "pi",
269
- type: "app",
270
- category,
271
- project: path.basename(root) || undefined,
272
- project_root_count: countPathParts(root),
273
- };
219
+ return { entity: "pi", type: "app", category, project: path.basename(root) || undefined, project_root_count: countPathParts(root) };
274
220
  }
275
221
 
276
222
  export function buildFileHeartbeat(absPath: string, cwd: string, category: Heartbeat["category"] = "ai coding"): Omit<Heartbeat, "time"> {
277
223
  const meta = buildProjectMeta(absPath, cwd);
278
- return {
279
- entity: absPath,
280
- type: "file",
281
- category,
282
- project: meta.project,
283
- project_root_count: meta.project_root_count,
284
- language: extToLanguage(absPath),
285
- lines: countLines(absPath),
286
- };
224
+ return { entity: absPath, type: "file", category, project: meta.project, project_root_count: meta.project_root_count, language: extToLanguage(absPath), lines: countLines(absPath) };
287
225
  }
288
226
 
289
- // ─── Setup ──────────────────────────────────────────────────────────────────
290
-
291
- export function resetWakatimeStateForTests(): void {
227
+ export function resetWakatimeStateForTests() {
292
228
  active = null;
293
229
  clearTimer();
294
230
  if (terminalInputUnsub) terminalInputUnsub();
@@ -296,6 +232,42 @@ export function resetWakatimeStateForTests(): void {
296
232
  cachedWakatimeCliPath = undefined;
297
233
  }
298
234
 
235
+ // ─── Module + setup ──────────────────────────────────────────────────────
236
+
237
+ export const wakatimeModule: Module = {
238
+ name: "wakatime",
239
+ hooks: {
240
+ session_start: [
241
+ (_event, ctx) => {
242
+ active = null;
243
+ // Timer is set up in setupWakatime (needs API key + CLI).
244
+ },
245
+ ],
246
+ session_shutdown: [
247
+ () => {
248
+ active = null;
249
+ clearTimer();
250
+ if (terminalInputUnsub) terminalInputUnsub();
251
+ terminalInputUnsub = null;
252
+ },
253
+ ],
254
+ before_agent_start: [
255
+ (_event, ctx) => {
256
+ const cwd = ctx.cwd ?? process.cwd();
257
+ // Caller (setupWakatime) provides the actual sender; this module's
258
+ // before_agent_start is a no-op when not configured.
259
+ if (active && (Date.now() - active.lastActivityAt) <= IDLE_TIMEOUT_MS) {
260
+ active.heartbeat = buildAppHeartbeat(cwd, active.heartbeat.category);
261
+ active.lastActivityAt = Date.now();
262
+ }
263
+ },
264
+ ],
265
+ agent_end: [
266
+ () => { touchActivity(); },
267
+ ],
268
+ },
269
+ };
270
+
299
271
  export function setupWakatimeWithApiKey(
300
272
  pi: ExtensionAPI,
301
273
  apiKey: string | undefined,
@@ -312,7 +284,6 @@ export function setupWakatimeWithApiKey(
312
284
  if (terminalInputUnsub) terminalInputUnsub();
313
285
  terminalInputUnsub = null;
314
286
  if (!ctx.hasUI) return;
315
-
316
287
  terminalInputUnsub = ctx.ui.onTerminalInput(() => {
317
288
  const cwd = ctx.cwd ?? process.cwd();
318
289
  const recent = active && (Date.now() - active.lastActivityAt) <= IDLE_TIMEOUT_MS;
@@ -335,69 +306,96 @@ export function setupWakatimeWithApiKey(
335
306
 
336
307
  pi.on("before_agent_start", (_event, ctx) => {
337
308
  const cwd = ctx.cwd ?? process.cwd();
338
-
339
- // Keep the long-lived active heartbeat on the app entity (`pi`) so
340
- // keepalive time accrues to the Pi editor itself. File heartbeats are sent
341
- // separately as one-shots below; if we replaced `active` with a file entity,
342
- // the dashboard would attribute nearly all time to the last touched file and
343
- // Pi editor time would collapse toward zero.
344
309
  switchActive(buildAppHeartbeat(cwd), sendHeartbeat, { immediate: true, cwd });
345
310
  });
346
311
 
312
+ // File-level heartbeats as one-shots in tool_result (don't replace the active keepalive).
347
313
  pi.on("tool_result", (event, ctx) => {
348
314
  const cwd = ctx.cwd ?? process.cwd();
349
315
  const input = (event as any).input;
350
-
351
- // File-level operations send one-shot heartbeats but never replace the
352
- // keepalive entity — the app heartbeat stays active for accurate editor
353
- // time attribution on the WakaTime dashboard.
354
316
  if (event.toolName === "read") {
355
317
  const filePath = input?.path ?? input?.file ?? input?.file_path;
356
318
  if (typeof filePath !== "string" || !filePath.trim()) return;
357
319
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
358
320
  sendOneShot(buildFileHeartbeat(absPath, cwd), sendHeartbeat, undefined, cwd);
359
321
  touchActivity();
360
- return;
361
- }
362
-
363
- if (event.toolName === "patch") {
322
+ } else if (event.toolName === "patch") {
364
323
  const filePath = input?.path;
365
324
  if (typeof filePath !== "string" || !filePath.trim()) return;
366
325
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
367
326
  sendOneShot(buildFileHeartbeat(absPath, cwd), sendHeartbeat, true, cwd);
368
327
  touchActivity();
369
- return;
328
+ } else if (event.toolName === "lsp_diagnostics") {
329
+ touchActivity();
330
+ } else if (event.toolName === "bash") {
331
+ const category = classifyBash(input?.command);
332
+ switchActive(buildAppHeartbeat(cwd, category), sendHeartbeat, { cwd });
333
+ touchActivity();
334
+ } else {
335
+ touchActivity();
370
336
  }
337
+ });
371
338
 
372
- if (event.toolName === "lsp_document_symbols") {
373
- const filePath = input?.path;
374
- if (typeof filePath !== "string" || !filePath.trim()) return;
339
+ pi.on("agent_end", (_event, _ctx) => { touchActivity(); });
340
+ }
341
+
342
+ export function setupWakatime(sk: Skeleton, pi: ExtensionAPI): void {
343
+ const apiKey = readWakatimeCfgApiKey();
344
+ const cliPath = findWakatimeCli();
345
+ sk.declareDependency({
346
+ label: "wakatime-cli",
347
+ check: () => findWakatimeCli() !== null,
348
+ hint: "Install wakatime-cli to track coding activity.",
349
+ });
350
+ if (!apiKey || !cliPath) return;
351
+
352
+ const sendHeartbeat: HeartbeatSender = (hb, cwd) => sendHeartbeatViaCli(hb, apiKey, cliPath, cwd);
353
+
354
+ // Hook into session_start to set up timer + terminal input.
355
+ pi.on("session_start", (_event, ctx) => {
356
+ active = null;
357
+ ensureTimer(sendHeartbeat);
358
+ if (terminalInputUnsub) terminalInputUnsub();
359
+ terminalInputUnsub = null;
360
+ if (!ctx.hasUI) return;
361
+ terminalInputUnsub = ctx.ui.onTerminalInput(() => {
362
+ const cwd = ctx.cwd ?? process.cwd();
363
+ const recent = active && (Date.now() - active.lastActivityAt) <= IDLE_TIMEOUT_MS;
364
+ const isAppHeartbeat = active?.heartbeat.type === "app" && active.heartbeat.entity === "pi";
365
+ if (!recent || !isAppHeartbeat) {
366
+ switchActive(buildAppHeartbeat(cwd), sendHeartbeat, { immediate: true, cwd });
367
+ } else {
368
+ touchActivity();
369
+ }
370
+ return undefined;
371
+ });
372
+ });
373
+
374
+ // Tool result: auto-detect file operations, classify bash, touch activity
375
+ pi.on("tool_result", (event, ctx) => {
376
+ const cwd = ctx.cwd ?? process.cwd();
377
+ const input = (event as any).input;
378
+
379
+ // Auto-detect file operations from input shape
380
+ const filePath = input?.path ?? input?.file ?? input?.file_path;
381
+ if (typeof filePath === "string" && filePath.trim()) {
375
382
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
376
383
  sendOneShot(buildFileHeartbeat(absPath, cwd), sendHeartbeat, undefined, cwd);
377
384
  touchActivity();
378
385
  return;
379
386
  }
380
387
 
381
- if (event.toolName === "lsp_diagnostics") {
382
- touchActivity();
383
- return;
384
- }
385
-
388
+ // Bash needs special classification
386
389
  if (event.toolName === "bash") {
387
390
  const category = classifyBash(input?.command);
388
- switchActive(buildAppHeartbeat(cwd, category), sendHeartbeat, { cwd });
391
+ if (active) active.heartbeat = { ...active.heartbeat, category };
392
+ touchActivity();
389
393
  return;
390
394
  }
391
395
 
396
+ // Everything else: just touch activity
392
397
  touchActivity();
393
398
  });
394
399
 
395
- pi.on("agent_end", (_event, _ctx) => {
396
- // Do not clear immediately; keepalive continues briefly until idle timeout.
397
- touchActivity();
398
- });
399
- }
400
-
401
- export function setupWakatime(pi: ExtensionAPI) {
402
- setupWakatimeWithApiKey(pi, getApiKey(), findWakatimeCli());
400
+ sk.register(wakatimeModule);
403
401
  }
package/index.ts CHANGED
@@ -1 +1,155 @@
1
- export { default } from "./extensions/index";
1
+ /**
2
+ * decorated-pi — entry point.
3
+ *
4
+ * Three categories:
5
+ * - tools/ : LLM-callable tools
6
+ * - hooks/ : event handlers (registered via skeleton)
7
+ * - commands/ : slash commands
8
+ *
9
+ * Plus:
10
+ * - system-prompt guidelines: hard-coded base + per-module imports,
11
+ * concatenated in array order, injected via pi.on("before_agent_start", ...)
12
+ * in `installGuidelines` below.
13
+ *
14
+ * Skeleton is the only place that calls pi.on(...) for hooks. Tools,
15
+ * commands, and guideline injection register themselves directly with pi.
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+
20
+ import { createSkeleton } from "./hooks/skeleton.js";
21
+
22
+ import { setupRedact, REDACT_GUIDANCE } from "./hooks/redact.js";
23
+ import { externalizeModule } from "./hooks/externalize.js";
24
+ import { normalizeCodeblocksModule } from "./hooks/normalize-codeblocks.js";
25
+ import { trackMtimeModule } from "./hooks/track-mtime.js";
26
+ import { injectAgentsMdModule, INJECT_AGENTS_MD_GUIDANCE } from "./hooks/inject-agents-md.js";
27
+ import { imageVisionModule } from "./hooks/image-vision.js";
28
+ import { smartAtModule } from "./hooks/smart-at.js";
29
+ import { sessionTitleModule } from "./hooks/session-title.js";
30
+ import { piToolFilterModule } from "./hooks/pi-tool-filter.js";
31
+ import { setupCompaction } from "./hooks/compaction.js";
32
+ import { mcpModule } from "./hooks/mcp.js";
33
+ import { setupWakatime, wakatimeModule } from "./hooks/wakatime.js";
34
+ import { setupRtk } from "./hooks/rtk.js";
35
+
36
+ import { registerPatchTool } from "./tools/patch/index.js";
37
+ import { registerLspTools } from "./tools/lsp/tools.js";
38
+ import { LspServerManager } from "./tools/lsp/manager.js";
39
+ import { resolveMcpConfigs } from "./tools/mcp/config.js";
40
+ import { ensureMcpServerReady } from "./hooks/mcp.js";
41
+ import { CODEGRAPH_GUIDANCE } from "./tools/mcp/builtin/codegraph.js";
42
+
43
+ import { registerDpModelCommand } from "./commands/dp-model.js";
44
+ import { registerDpSettingsCommand } from "./commands/dp-settings.js";
45
+ import { registerMcpStatusCommand } from "./commands/mcp-status.js";
46
+ import { registerRetryCommand } from "./commands/retry.js";
47
+ import { registerUsageCommand } from "./commands/usage.js";
48
+
49
+ import { isModuleEnabled } from "./settings.js";
50
+
51
+ // ─── System-prompt guidelines (hard-coded base, per-module imports) ────────
52
+ //
53
+ // Array order = prompt order. Add a new module's guidance by importing
54
+ // its constant and pushing it here. No priority / sort logic — just push.
55
+
56
+ const BASE_GUIDANCE = [
57
+ "## Decorated Pi Guidance",
58
+ "",
59
+ "### Workflow, how to approach tasks",
60
+ "- Before acting on a prompt: 1. ensure you fully understand the user's intent — if ambiguous, ask clarifying questions; 2. have researched the existing state — read files, search, investigate. Proceed only when both are clear.",
61
+ "- Exercise caution when performing any **write** operations, especially when you are in a research or exploration phase.",
62
+ "- Before modifying code, match the user's existing code style (naming, formatting, patterns). Do not re-modify lines the user has manually edited since your last change.",
63
+ "",
64
+ "### Filesystem Safety, where NOT to write",
65
+ "- CAUTION: Do not perform write operations in the following directories unless explicitly instructed: `node_modules`, `venv`, `env`, `__pycache__`, `.git` or any other hidden directories.",
66
+ ].join("\n");
67
+
68
+ /** Build the list of guideline strings to inject, in prompt order.
69
+ * Always-on rules first, then per-module guidelines (skipping disabled
70
+ * modules). Add new per-module guidelines by importing the constant
71
+ * here and pushing it inside an `isModuleEnabled(...)` guard. */
72
+ function buildGuidelines(): string[] {
73
+ const out: string[] = [
74
+ BASE_GUIDANCE,
75
+ REDACT_GUIDANCE, // from hooks/redact.ts — always on (safety module)
76
+ INJECT_AGENTS_MD_GUIDANCE, // from hooks/inject-agents-md.ts — always on (smart-at module)
77
+ ];
78
+ if (isModuleEnabled("codegraph")) out.push(CODEGRAPH_GUIDANCE);
79
+ return out;
80
+ }
81
+
82
+ /** Install a single before_agent_start handler that appends every
83
+ * guideline in order, stripping the volatile "Current date: …" line
84
+ * for cache stability. Idempotent — re-injection is a no-op via marker. */
85
+ function installGuidelines(pi: ExtensionAPI): void {
86
+ const blocks = buildGuidelines();
87
+ const joined = blocks.join("\n\n");
88
+ const marker = "## Decorated Pi Guidance";
89
+
90
+ pi.on("before_agent_start", async (event: any) => {
91
+ if (!event.systemPrompt) return undefined;
92
+ let prompt: string = event.systemPrompt.replace(/\nCurrent date: \d{4}-\d{2}-\d{2}/, "");
93
+ if (prompt.includes(marker)) return undefined; // already injected this turn
94
+ return { systemPrompt: `${prompt}\n\n${joined}` };
95
+ });
96
+ }
97
+
98
+ export default async function (pi: ExtensionAPI) {
99
+ // ── Skeleton (hooks) ───────────────────────────────────────────────────
100
+ const sk = createSkeleton();
101
+
102
+ // Order matters for tool_result compose chain:
103
+ // 1. redact → normalize-codeblocks → externalize → track-mtime → inject-agents-md → image-vision → wakatime
104
+ // The first module registered for a given event runs first (compose chain).
105
+ setupRedact(sk);
106
+ sk.register(normalizeCodeblocksModule);
107
+ sk.register(externalizeModule);
108
+ sk.register(trackMtimeModule);
109
+ sk.register(injectAgentsMdModule);
110
+ sk.register(imageVisionModule);
111
+
112
+ // session_start handlers (parallel)
113
+ // pi-tool-filter must register first so native tools are dropped before
114
+ // anything else inspects the tool list.
115
+ sk.register(piToolFilterModule);
116
+ sk.register(sessionTitleModule);
117
+ sk.register(smartAtModule);
118
+ sk.register(wakatimeModule);
119
+
120
+ // Compaction + RTK (these also install their own pi.on via setup<>()).
121
+ setupCompaction(sk);
122
+ setupRtk(sk, pi);
123
+ setupWakatime(sk, pi);
124
+
125
+ // ── Tools (conditional on module switches) ────────────────────────────
126
+ if (isModuleEnabled("patch")) registerPatchTool(pi);
127
+ if (isModuleEnabled("lsp")) registerLspTools(pi, new LspServerManager());
128
+
129
+ // MCP: hook AND tool are gated together. Disabling the module
130
+ // means no session_start handler runs, no tools register, and no
131
+ // background connections are attempted.
132
+ if (isModuleEnabled("mcp")) {
133
+ sk.register(mcpModule);
134
+ const configs = resolveMcpConfigs(process.cwd()).filter(s => s.enabled);
135
+ // Per-server readiness: cache hit → register from cache (fast).
136
+ // Cache miss → connect synchronously, write cache, then register
137
+ // live tools. This blocks startup only for cache-miss servers.
138
+ for (const config of configs) {
139
+ await ensureMcpServerReady(pi, config, process.cwd());
140
+ }
141
+ }
142
+
143
+ // ── System-prompt guidelines (single handler, array order = prompt order) ──
144
+ installGuidelines(pi);
145
+
146
+ // ── Commands ──────────────────────────────────────────────────────────
147
+ registerDpModelCommand(pi);
148
+ registerDpSettingsCommand(pi);
149
+ registerMcpStatusCommand(pi);
150
+ registerRetryCommand(pi);
151
+ registerUsageCommand(pi);
152
+
153
+ // ── Install skeleton (last) ────────────────────────────────────────────
154
+ sk.install(pi);
155
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.5.4",
4
- "description": "decorated-pi is a practical enhancement pack for pi smarter tools that are token efficient and cache friendly.",
3
+ "version": "0.6.0",
4
+ "description": "decorated-pi is a practical enhancement pack for pi coding agent token-efficient workflow, cache-friendly design, and smarter tools.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "pi": {
35
35
  "extensions": [
36
- "./extensions/index.ts"
36
+ "./index.ts"
37
37
  ]
38
38
  },
39
39
  "devDependencies": {
@@ -45,6 +45,7 @@
45
45
  "scripts": {
46
46
  "prepack": "depcheck --fail-on-missing",
47
47
  "test": "vitest run",
48
- "prepublishOnly": "npm install --no-audit --no-fund && npm test && depcheck --fail-on-missing && publint"
48
+ "typecheck": "tsc --noEmit",
49
+ "prepublishOnly": "npm install --no-audit --no-fund && npm test && npm run typecheck && depcheck --fail-on-missing && publint"
49
50
  }
50
51
  }
@@ -42,6 +42,13 @@ export interface ModuleSettings {
42
42
  mcp?: boolean;
43
43
  wakatime?: boolean;
44
44
  "rtk"?: boolean;
45
+ codegraph?: boolean;
46
+ }
47
+
48
+ export interface UsageIndexEntry {
49
+ inode: number;
50
+ size: number;
51
+ mtime: number;
45
52
  }
46
53
 
47
54
  export interface DecoratedPiConfig {
@@ -50,6 +57,7 @@ export interface DecoratedPiConfig {
50
57
  providers?: Record<string, ProviderCache>;
51
58
  modules?: ModuleSettings;
52
59
  mcpServers?: Record<string, McpServerEntry>;
60
+ usageIndex?: Record<string, UsageIndexEntry>;
53
61
  }
54
62
 
55
63
  export function loadConfig(): DecoratedPiConfig {
@@ -151,6 +159,7 @@ const DEFAULT_MODULES: Required<ModuleSettings> = {
151
159
  mcp: true,
152
160
  wakatime: true,
153
161
  "rtk": true,
162
+ codegraph: false, // opt-in: heavy binary, not everyone wants it
154
163
  };
155
164
 
156
165
  export function isModuleEnabled(name: keyof ModuleSettings): boolean {
@@ -168,3 +177,26 @@ export function getAllModuleSettings(): Required<ModuleSettings> {
168
177
  const modules = loadConfig().modules ?? {};
169
178
  return { ...DEFAULT_MODULES, ...modules };
170
179
  }
180
+
181
+ // ─── Codegraph (drives the builtin MCP server's auto-enable) ───────────────
182
+
183
+ /**
184
+ * True when the user has enabled the codegraph module via /dp-settings.
185
+ * Default is OFF (opt-in) — `modules.codegraph` defaults to `false`.
186
+ * When true, the builtin MCP server is enabled and the CodeGraph
187
+ * guidance section is injected into the system prompt.
188
+ */
189
+ export function isCodegraphModuleEnabled(): boolean {
190
+ return isModuleEnabled("codegraph");
191
+ }
192
+
193
+ // ─── Usage index (增量同步元数据) ─────────────────────────────────────────────
194
+
195
+ /** 缓存的上次同步状态: { 文件路径 → { inode, size, mtime } } */
196
+ export function loadUsageIndex(): Record<string, UsageIndexEntry> {
197
+ return loadConfig().usageIndex ?? {};
198
+ }
199
+
200
+ export function saveUsageIndex(index: Record<string, UsageIndexEntry>) {
201
+ saveConfig({ usageIndex: index });
202
+ }