decorated-pi 0.7.2 → 0.8.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/README.md +21 -3
- package/commands/dp-settings.ts +3 -3
- package/commands/usage.ts +5 -1
- package/hooks/compaction.ts +52 -157
- package/hooks/externalize.ts +48 -35
- package/hooks/rtk.ts +24 -97
- package/hooks/skeleton.ts +57 -19
- package/hooks/smart-at.ts +69 -30
- package/hooks/thinking-label-strip.ts +79 -0
- package/hooks/wakatime.ts +17 -30
- package/index.ts +207 -170
- package/package.json +3 -1
- package/settings.ts +159 -1
- package/tools/ask/index.ts +1 -4
- package/tools/lsp/servers.ts +31 -15
- package/tools/mcp/config.ts +34 -15
- package/tools/patch/core.ts +113 -24
- package/tools/patch/encoding.ts +172 -0
- package/tsconfig.json +1 -0
- package/ui/ask.ts +72 -22
- package/ui/module-settings.ts +166 -16
- package/utils/which.ts +109 -0
package/hooks/skeleton.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { isDontBother } from "../settings.js";
|
|
14
15
|
|
|
15
16
|
// ─── Event union ───────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -24,7 +25,8 @@ export type HookEvent =
|
|
|
24
25
|
| "agent_end"
|
|
25
26
|
| "input"
|
|
26
27
|
| "tool_call"
|
|
27
|
-
| "tool_result"
|
|
28
|
+
| "tool_result"
|
|
29
|
+
| "message_end";
|
|
28
30
|
|
|
29
31
|
// ─── Handler modes ─────────────────────────────────────────────────────────
|
|
30
32
|
|
|
@@ -42,29 +44,39 @@ export type ComposeHandler<E extends HookEvent> = (
|
|
|
42
44
|
pi: ExtensionAPI,
|
|
43
45
|
) => any | Promise<any>;
|
|
44
46
|
|
|
47
|
+
/** Result: each handler sees the original event; the last non-undefined
|
|
48
|
+
* return value wins. Matches runner.emit()'s behavior for `session_before_*`
|
|
49
|
+
* events, where the runner collects a single `{ cancel?, compaction? }`
|
|
50
|
+
* result from all extensions. Use for events whose contract is "the
|
|
51
|
+
* extension either overrides or steps aside". */
|
|
52
|
+
export type ResultHandler<E extends HookEvent> = (
|
|
53
|
+
event: any,
|
|
54
|
+
ctx: ExtensionContext,
|
|
55
|
+
pi: ExtensionAPI,
|
|
56
|
+
) => any | Promise<any>;
|
|
57
|
+
|
|
45
58
|
export interface Module {
|
|
46
59
|
readonly name: string;
|
|
47
60
|
readonly hooks: {
|
|
48
61
|
session_start?: ParallelHandler<"session_start">[];
|
|
49
62
|
session_shutdown?: ParallelHandler<"session_shutdown">[];
|
|
50
63
|
session_compact?: ParallelHandler<"session_compact">[];
|
|
51
|
-
session_before_compact?:
|
|
64
|
+
session_before_compact?: ResultHandler<"session_before_compact">[];
|
|
52
65
|
before_agent_start?: ComposeHandler<"before_agent_start">[];
|
|
53
66
|
agent_start?: ParallelHandler<"agent_start">[];
|
|
54
67
|
agent_end?: ParallelHandler<"agent_end">[];
|
|
55
68
|
input?: ParallelHandler<"input">[];
|
|
56
69
|
tool_call?: ComposeHandler<"tool_call">[];
|
|
57
70
|
tool_result?: ComposeHandler<"tool_result">[];
|
|
71
|
+
message_end?: ComposeHandler<"message_end">[];
|
|
58
72
|
};
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
// ─── Declarations ──────────────────────────────────────────────────────────
|
|
62
76
|
|
|
63
77
|
export interface Dependency {
|
|
64
|
-
|
|
65
|
-
check: () => boolean;
|
|
78
|
+
name: string;
|
|
66
79
|
hint?: string;
|
|
67
|
-
/** Display/source tag for inspection and notifications. */
|
|
68
80
|
module?: string;
|
|
69
81
|
}
|
|
70
82
|
|
|
@@ -75,9 +87,12 @@ export interface Dependency {
|
|
|
75
87
|
* uses `Dependency.check` directly rather than collecting statuses. */
|
|
76
88
|
export interface DependencyStatus {
|
|
77
89
|
module: string;
|
|
90
|
+
/** Binary/config key, not necessarily the resolved absolute path. */
|
|
78
91
|
label: string;
|
|
79
92
|
state: "ok" | "missing";
|
|
80
93
|
detail: string;
|
|
94
|
+
/** Resolved executable path when state is ok. */
|
|
95
|
+
path?: string;
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
// ─── Skeleton ──────────────────────────────────────────────────────────────
|
|
@@ -86,12 +101,26 @@ const COMPOSE_EVENTS = new Set<HookEvent>([
|
|
|
86
101
|
"before_agent_start",
|
|
87
102
|
"tool_call",
|
|
88
103
|
"tool_result",
|
|
104
|
+
"message_end",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
/** Events whose handler return value is propagated to the extension runner
|
|
108
|
+
* (no chaining — each handler sees the original event, last non-undefined
|
|
109
|
+
* return wins). Required for `session_before_compact`, whose contract is
|
|
110
|
+
* `{ cancel?, compaction? }`; without this, hooks can't override pi's
|
|
111
|
+
* default compaction. */
|
|
112
|
+
const RESULT_EVENTS = new Set<HookEvent>([
|
|
113
|
+
"session_before_compact",
|
|
89
114
|
]);
|
|
90
115
|
|
|
91
116
|
export interface Skeleton {
|
|
92
117
|
register(module: Module): void;
|
|
93
118
|
/** Returns whether the dependency check passed right now. */
|
|
94
|
-
|
|
119
|
+
/** Declare that a binary dependency is missing. Module calls this
|
|
120
|
+
* after its own which() lookup failed. Skeleton dedupes by name,
|
|
121
|
+
* honors `dependencies[name].dontBother`, and shows a single
|
|
122
|
+
* "run /dp-settings" notification on session_start. */
|
|
123
|
+
declareMissing(dep: Omit<Dependency, "module"> & { module?: string }): void;
|
|
95
124
|
install(pi: ExtensionAPI): void;
|
|
96
125
|
inspect(): Inspection;
|
|
97
126
|
}
|
|
@@ -99,7 +128,7 @@ export interface Skeleton {
|
|
|
99
128
|
export interface Inspection {
|
|
100
129
|
modules: string[];
|
|
101
130
|
events: Record<string, Array<{ module: string; order: number }>>;
|
|
102
|
-
dependencies: Array<{
|
|
131
|
+
dependencies: Array<{ name: string; module?: string; hint?: string }>;
|
|
103
132
|
}
|
|
104
133
|
|
|
105
134
|
export function createSkeleton(): Skeleton {
|
|
@@ -125,13 +154,10 @@ export function createSkeleton(): Skeleton {
|
|
|
125
154
|
collect(mod);
|
|
126
155
|
},
|
|
127
156
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
} catch {
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
157
|
+
declareMissing(dep) {
|
|
158
|
+
// Dedupe by name — multiple modules may depend on the same binary.
|
|
159
|
+
if (dependencies.some((d) => d.name === dep.name)) return;
|
|
160
|
+
dependencies.push(dep as Dependency);
|
|
135
161
|
},
|
|
136
162
|
|
|
137
163
|
install(pi) {
|
|
@@ -147,6 +173,15 @@ export function createSkeleton(): Skeleton {
|
|
|
147
173
|
}
|
|
148
174
|
return current === event ? undefined : current;
|
|
149
175
|
});
|
|
176
|
+
} else if (RESULT_EVENTS.has(event)) {
|
|
177
|
+
pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
|
|
178
|
+
let result;
|
|
179
|
+
for (const { handler } of handlers) {
|
|
180
|
+
const r = await handler(event, ctx, pi);
|
|
181
|
+
if (r !== undefined) result = r;
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
});
|
|
150
185
|
} else {
|
|
151
186
|
pi.on(event as any, async (event: any, ctx: ExtensionContext) => {
|
|
152
187
|
for (const { handler } of handlers) await handler(event, ctx, pi);
|
|
@@ -165,13 +200,16 @@ export function createSkeleton(): Skeleton {
|
|
|
165
200
|
dependencyNotifyTimer = undefined;
|
|
166
201
|
const missing: string[] = [];
|
|
167
202
|
for (const dep of dependencies) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
203
|
+
// dontBother flag silences the notification per-binary.
|
|
204
|
+
if (isDontBother(dep.name)) continue;
|
|
205
|
+
missing.push(dep.name);
|
|
171
206
|
}
|
|
172
207
|
if (missing.length) {
|
|
173
208
|
try {
|
|
174
|
-
ctx.ui.notify(
|
|
209
|
+
ctx.ui.notify(
|
|
210
|
+
`[decorated-pi] Some dependencies are missing (${missing.length}). Run /dp-settings → Dependencies to configure.`,
|
|
211
|
+
"info",
|
|
212
|
+
);
|
|
175
213
|
} catch {
|
|
176
214
|
// Extension context may be stale if another reload/session switch happened.
|
|
177
215
|
}
|
|
@@ -207,7 +245,7 @@ export function createSkeleton(): Skeleton {
|
|
|
207
245
|
return {
|
|
208
246
|
modules: modules.map((m) => m.name),
|
|
209
247
|
events,
|
|
210
|
-
dependencies: dependencies.map((d) => ({
|
|
248
|
+
dependencies: dependencies.map((d) => ({ name: d.name, module: d.module, hint: d.hint })),
|
|
211
249
|
};
|
|
212
250
|
},
|
|
213
251
|
};
|
package/hooks/smart-at.ts
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* frecency + git status, and returns ranked results. We create one
|
|
7
7
|
* FileFinder per session and query it directly for every @ prefix.
|
|
8
8
|
*
|
|
9
|
+
* Result handling trusts FFF's native score: re-sort by score
|
|
10
|
+
* descending (shorter path breaks ties), no substring or git-ignore
|
|
11
|
+
* post-filtering.
|
|
12
|
+
*
|
|
9
13
|
* KNOWN FFF LIMITATION (v0.9.4):
|
|
10
14
|
* FFF only returns directories that directly contain files. Intermediate
|
|
11
15
|
* folders that only hold subdirectories (e.g. product/module/apmanage/ where
|
|
@@ -24,7 +28,6 @@ import type { Module, Skeleton } from "./skeleton.js";
|
|
|
24
28
|
const AT_BOUNDARY = new Set<string>([" ", "\t", "(", "["]);
|
|
25
29
|
|
|
26
30
|
const AUTOCOMPLETE_LIMIT = 20;
|
|
27
|
-
const FFF_SUPERSET = 100;
|
|
28
31
|
const WIDGET_FOOTER = "\x1b[2mpowered by fff\x1b[0m";
|
|
29
32
|
|
|
30
33
|
interface AutocompleteItem {
|
|
@@ -45,10 +48,6 @@ function atPrefix(text: string): string | null {
|
|
|
45
48
|
return null;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
function isGitIgnored(item: MixedItem): boolean {
|
|
49
|
-
return item.type === "file" && item.item.gitStatus === "ignored";
|
|
50
|
-
}
|
|
51
|
-
|
|
52
51
|
function toAutocompleteItem(item: MixedItem): AutocompleteItem {
|
|
53
52
|
const path = item.item.relativePath;
|
|
54
53
|
const label = item.type === "file"
|
|
@@ -65,23 +64,20 @@ interface BuiltResult {
|
|
|
65
64
|
items: AutocompleteItem[];
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
/** Take FFF's
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
function buildResult(items: MixedItem[],
|
|
73
|
-
const
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
(it) =>
|
|
77
|
-
!lowerQuery || it.item.relativePath.toLowerCase().includes(lowerQuery),
|
|
78
|
-
)
|
|
67
|
+
/** Take FFF's mixed-search results and re-sort by native score descending
|
|
68
|
+
* (score ties broken by shorter path first), then map to autocomplete
|
|
69
|
+
* items. Trust FFF's fuzzy+frecency score; no substring or git-ignore
|
|
70
|
+
* post-filtering. */
|
|
71
|
+
function buildResult(items: MixedItem[], scores: { total: number }[]): BuiltResult | null {
|
|
72
|
+
const ranked = items
|
|
73
|
+
.map((item, index) => ({ item, score: scores[index]?.total ?? 0 }))
|
|
74
|
+
.sort((a, b) => b.score - a.score || a.item.item.relativePath.length - b.item.item.relativePath.length)
|
|
79
75
|
.slice(0, AUTOCOMPLETE_LIMIT)
|
|
80
|
-
.map(toAutocompleteItem);
|
|
81
|
-
return
|
|
76
|
+
.map((entry) => toAutocompleteItem(entry.item));
|
|
77
|
+
return ranked.length ? { items: ranked } : null;
|
|
82
78
|
}
|
|
83
79
|
|
|
84
|
-
export const __smartAtTest = { atPrefix };
|
|
80
|
+
export const __smartAtTest = { atPrefix, buildResult };
|
|
85
81
|
|
|
86
82
|
/** Active FileFinder for the current session; freed in session_shutdown
|
|
87
83
|
* to avoid leaking FFF's native handle + LMDB mmap regions. */
|
|
@@ -93,7 +89,15 @@ export const smartAtModule: Module = {
|
|
|
93
89
|
session_start: [
|
|
94
90
|
async (_event: any, ctx: ExtensionContext) => {
|
|
95
91
|
const cwd = String(ctx.cwd || "").trim();
|
|
96
|
-
|
|
92
|
+
// Always opt in to home/root scanning. These flags are opt-in guards
|
|
93
|
+
// in FFF — when cwd is a normal project, they're no-ops; when cwd
|
|
94
|
+
// IS $HOME or /, they let FFF index it. Without them, create() fails
|
|
95
|
+
// outright when cwd is a home/root, leaving the user without @-search.
|
|
96
|
+
const created = FileFinder.create({
|
|
97
|
+
basePath: cwd || ".",
|
|
98
|
+
enableHomeDirScanning: true,
|
|
99
|
+
enableFsRootScanning: true,
|
|
100
|
+
});
|
|
97
101
|
if (!created.ok) {
|
|
98
102
|
// FFF not available on this platform; silently skip.
|
|
99
103
|
return;
|
|
@@ -102,11 +106,18 @@ export const smartAtModule: Module = {
|
|
|
102
106
|
const finder = created.value;
|
|
103
107
|
currentFinder = finder;
|
|
104
108
|
|
|
109
|
+
let scanWidgetVisible = false;
|
|
110
|
+
|
|
105
111
|
// Start the scan in the background. We don't wait for it here so
|
|
106
|
-
// session_start returns immediately
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
void finder.waitForScan(60_000)
|
|
112
|
+
// session_start returns immediately. If a scanning status was shown,
|
|
113
|
+
// clear it when the scan finishes even if no new autocomplete request
|
|
114
|
+
// is triggered afterwards.
|
|
115
|
+
void finder.waitForScan(60_000).then(() => {
|
|
116
|
+
if (currentFinder === finder && !finder.isDestroyed && scanWidgetVisible) {
|
|
117
|
+
scanWidgetVisible = false;
|
|
118
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
110
121
|
|
|
111
122
|
ctx.ui.addAutocompleteProvider((orig: any) => ({
|
|
112
123
|
getSuggestions: (
|
|
@@ -133,27 +144,55 @@ export const smartAtModule: Module = {
|
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
const query = prefix.slice(1);
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
pageSize: lowerQuery ? FFF_SUPERSET : AUTOCOMPLETE_LIMIT,
|
|
147
|
+
const r = finder.mixedSearch(query.toLowerCase(), {
|
|
148
|
+
pageSize: AUTOCOMPLETE_LIMIT,
|
|
139
149
|
});
|
|
140
150
|
if (!r.ok) {
|
|
141
151
|
ctx.ui.setWidget("smart-at", undefined);
|
|
142
152
|
return null;
|
|
143
153
|
}
|
|
144
154
|
|
|
145
|
-
|
|
155
|
+
// 0 items during the initial scan means FFF is not ready yet.
|
|
156
|
+
// Autocomplete has no non-selectable dropdown state: returning an
|
|
157
|
+
// item would force SelectList to render a selectable "→ ..." row.
|
|
158
|
+
// So use a static below-editor widget while scanning, and clear it
|
|
159
|
+
// once the scan completes (see waitForScan above). After scanning,
|
|
160
|
+
// 0 items just means "no match".
|
|
161
|
+
if (r.value.items.length === 0) {
|
|
162
|
+
if (finder.isScanning()) {
|
|
163
|
+
scanWidgetVisible = true;
|
|
164
|
+
ctx.ui.setWidget(
|
|
165
|
+
"smart-at",
|
|
166
|
+
["⏳ scanning… (indexing files, please wait)"],
|
|
167
|
+
{ placement: "belowEditor" },
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
scanWidgetVisible = false;
|
|
171
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = buildResult(r.value.items, r.value.scores);
|
|
146
177
|
if (!result) {
|
|
147
178
|
ctx.ui.setWidget("smart-at", undefined);
|
|
148
179
|
return null;
|
|
149
180
|
}
|
|
150
181
|
|
|
182
|
+
scanWidgetVisible = false;
|
|
151
183
|
ctx.ui.setWidget("smart-at", [WIDGET_FOOTER]);
|
|
152
184
|
return Promise.resolve({ ...result, prefix });
|
|
153
185
|
},
|
|
154
|
-
applyCompletion: (
|
|
186
|
+
applyCompletion: (
|
|
187
|
+
lines: string[],
|
|
188
|
+
cl: number,
|
|
189
|
+
cc: number,
|
|
190
|
+
item: { value: string; label: string },
|
|
191
|
+
prefix: string,
|
|
192
|
+
) => {
|
|
193
|
+
scanWidgetVisible = false;
|
|
155
194
|
ctx.ui.setWidget("smart-at", undefined);
|
|
156
|
-
return orig.applyCompletion
|
|
195
|
+
return orig.applyCompletion(lines, cl, cc, item, prefix);
|
|
157
196
|
},
|
|
158
197
|
shouldTriggerFileCompletion:
|
|
159
198
|
orig.shouldTriggerFileCompletion?.bind(orig),
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* thinking-label-strip — remove stray <thinking></thinking> tags from
|
|
3
|
+
* assistant thinking blocks.
|
|
4
|
+
*
|
|
5
|
+
* Some models wrap their own reasoning text in literal <thinking></thinking>
|
|
6
|
+
* tags inside the `thinking` field of a ThinkingContent block. The tag
|
|
7
|
+
* itself is noise — pi already marks the block as type "thinking", so the
|
|
8
|
+
* tag is redundant. When it leaks into the context it confuses downstream
|
|
9
|
+
* consumers (compaction, other models that echo it back) and pollutes the
|
|
10
|
+
* session transcript.
|
|
11
|
+
*
|
|
12
|
+
* This hook intercepts `message_end` for assistant messages and strips
|
|
13
|
+
* every <thinking></thinking> pair from each ThinkingContent block's text,
|
|
14
|
+
* preserving the reasoning between the tags. The edit is persistent: the
|
|
15
|
+
* stored message is replaced, so the context sent to subsequent LLM calls
|
|
16
|
+
* no longer contains the literal tag.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Module } from "./skeleton.js";
|
|
20
|
+
|
|
21
|
+
const THINKING_OPEN = "<thinking>";
|
|
22
|
+
const THINKING_CLOSE = "</thinking>";
|
|
23
|
+
|
|
24
|
+
/** Strip a leading <thinking> and/or trailing </thinking> from `text`.
|
|
25
|
+
* Models often emit these tags as delimiters inside the `thinking` field;
|
|
26
|
+
* pi already marks the block type, so the tags are noise that leaks into
|
|
27
|
+
* the context and the TUI. We only trim at the head/tail — tags buried in
|
|
28
|
+
* the middle are part of the reasoning text and left alone, which avoids
|
|
29
|
+
* corrupting the model's argument if it literally discusses the tag. */
|
|
30
|
+
export function stripThinkingLabels(text: string): string {
|
|
31
|
+
if (!text.includes(THINKING_OPEN) && !text.includes(THINKING_CLOSE)) {
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
let out = text;
|
|
35
|
+
// trimStart would loop-strip repeating opens; we want a single leading
|
|
36
|
+
// open tag (optionally with whitespace before it) removed.
|
|
37
|
+
const leadingOpen = out.match(/^\s*<thinking>/);
|
|
38
|
+
if (leadingOpen) {
|
|
39
|
+
out = out.slice(leadingOpen[0].length);
|
|
40
|
+
}
|
|
41
|
+
const trailingClose = out.match(/<\/thinking>\s*$/);
|
|
42
|
+
if (trailingClose) {
|
|
43
|
+
out = out.slice(0, out.length - trailingClose[0].length);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Return a new content array with thinking blocks cleaned, or undefined
|
|
49
|
+
* if nothing changed. */
|
|
50
|
+
function cleanContent(content: any[]): any[] | undefined {
|
|
51
|
+
let changed = false;
|
|
52
|
+
const next = content.map((block) => {
|
|
53
|
+
if (block && block.type === "thinking" && typeof block.thinking === "string") {
|
|
54
|
+
const cleaned = stripThinkingLabels(block.thinking);
|
|
55
|
+
if (cleaned !== block.thinking) {
|
|
56
|
+
changed = true;
|
|
57
|
+
return { ...block, thinking: cleaned };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return block;
|
|
61
|
+
});
|
|
62
|
+
return changed ? next : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const thinkingLabelStripModule: Module = {
|
|
66
|
+
name: "thinking-label-strip",
|
|
67
|
+
hooks: {
|
|
68
|
+
message_end: [
|
|
69
|
+
(event: any) => {
|
|
70
|
+
const message = event?.message;
|
|
71
|
+
if (!message || message.role !== "assistant") return undefined;
|
|
72
|
+
if (!Array.isArray(message.content)) return undefined;
|
|
73
|
+
const cleaned = cleanContent(message.content);
|
|
74
|
+
if (!cleaned) return undefined;
|
|
75
|
+
return { message: { ...message, content: cleaned } };
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
};
|
package/hooks/wakatime.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { execFile
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
+
import { resolveDependency } from "../settings.js";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
15
16
|
|
|
@@ -122,36 +123,21 @@ export function buildPluginString(version = PACKAGE_VERSION): string {
|
|
|
122
123
|
return `pi/${version} pi/${version}`;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
export function wakatimeDependencyExtendPath(): string[] {
|
|
127
|
+
return [path.join(os.homedir(), ".wakatime")];
|
|
128
|
+
}
|
|
129
|
+
|
|
125
130
|
function findWakatimeCliOnPath(): string | null {
|
|
126
|
-
|
|
127
|
-
if (process.platform === "win32") {
|
|
128
|
-
const output = execFileSync("where", ["wakatime-cli"], { encoding: "utf-8" }).trim();
|
|
129
|
-
const first = output.split(/\r?\n/)[0]?.trim();
|
|
130
|
-
return first ? path.resolve(first) : null;
|
|
131
|
-
}
|
|
132
|
-
const shell = process.env.SHELL || "sh";
|
|
133
|
-
const output = execFileSync(shell, ["-lc", "command -v wakatime-cli"], { encoding: "utf-8" }).trim();
|
|
134
|
-
return output ? path.resolve(output) : null;
|
|
135
|
-
} catch {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
131
|
+
return resolveDependency("wakatime-cli", { extendPath: wakatimeDependencyExtendPath() });
|
|
138
132
|
}
|
|
139
133
|
|
|
140
134
|
export function findWakatimeCli(options: {
|
|
141
135
|
probePath?: () => string | null;
|
|
142
|
-
exists?: (candidate: string) => boolean;
|
|
143
|
-
fallbackPath?: string;
|
|
144
136
|
} = {}): string | null {
|
|
145
137
|
if (cachedWakatimeCliPath !== undefined) return cachedWakatimeCliPath;
|
|
146
138
|
const probePath = options.probePath ?? findWakatimeCliOnPath;
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
if (fromPath) {
|
|
150
|
-
cachedWakatimeCliPath = path.resolve(fromPath);
|
|
151
|
-
return cachedWakatimeCliPath;
|
|
152
|
-
}
|
|
153
|
-
const fallback = path.resolve(options.fallbackPath ?? WAKATIME_CLI_FALLBACK);
|
|
154
|
-
cachedWakatimeCliPath = exists(fallback) ? fallback : null;
|
|
139
|
+
const found = probePath();
|
|
140
|
+
cachedWakatimeCliPath = found ? path.resolve(found) : null;
|
|
155
141
|
return cachedWakatimeCliPath;
|
|
156
142
|
}
|
|
157
143
|
|
|
@@ -342,13 +328,14 @@ export function setupWakatimeWithApiKey(
|
|
|
342
328
|
export function setupWakatime(sk: Skeleton, pi: ExtensionAPI): void {
|
|
343
329
|
const apiKey = readWakatimeCfgApiKey();
|
|
344
330
|
const cliPath = findWakatimeCli();
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
331
|
+
if (!cliPath) {
|
|
332
|
+
sk.declareMissing({
|
|
333
|
+
name: "wakatime-cli",
|
|
334
|
+
module: "wakatime",
|
|
335
|
+
hint: "Install wakatime-cli to track coding activity.",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (!apiKey || !cliPath) return;
|
|
352
339
|
|
|
353
340
|
const sendHeartbeat: HeartbeatSender = (hb, cwd) => sendHeartbeatViaCli(hb, apiKey, cliPath, cwd);
|
|
354
341
|
|