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.
- package/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /package/{extensions → tools}/lsp/protocol.ts +0 -0
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* wakatime — coding activity heartbeats.
|
|
3
3
|
*
|
|
4
|
-
* Reads API key from ~/.wakatime.cfg and sends heartbeats
|
|
5
|
-
*
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"--
|
|
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.
|
|
211
|
-
a.
|
|
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)
|
|
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()
|
|
245
|
-
if (active) active.lastActivityAt = Date.now();
|
|
246
|
-
}
|
|
198
|
+
function touchActivity() { if (active) active.lastActivityAt = Date.now(); }
|
|
247
199
|
|
|
248
|
-
function ensureTimer(sendHeartbeat: HeartbeatSender)
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "decorated-pi is a practical enhancement pack for pi
|
|
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
|
-
"./
|
|
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
|
-
"
|
|
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
|
+
}
|