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/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?: ParallelHandler<"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
- label: string;
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
- declareDependency(dep: Dependency): boolean;
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<{ label: string; module?: string; hint?: string }>;
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
- declareDependency(dep) {
129
- dependencies.push(dep);
130
- try {
131
- return dep.check();
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
- let ok = false;
169
- try { ok = dep.check(); } catch { ok = false; }
170
- if (!ok) missing.push(dep.label);
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(`[decorated-pi] missing dependencies: ${missing.join(", ")}`, "info");
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) => ({ label: d.label, module: d.module, hint: d.hint })),
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 ranked results, drop git-ignored files, and for non-empty
69
- * queries keep only paths that contain the query as a substring. This
70
- * stops FFF's loose fuzzy matching from ranking every file in a directory
71
- * that happens to share a few letters (e.g. "mm/" for query "mmc"). */
72
- function buildResult(items: MixedItem[], lowerQuery: string): BuiltResult | null {
73
- const filtered = items
74
- .filter((it) => !isGitIgnored(it))
75
- .filter(
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 filtered.length ? { items: filtered } : null;
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
- const created = FileFinder.create({ basePath: cwd || "." });
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; the provider queries the
107
- // (possibly partial) index and FFF returns whatever it has indexed
108
- // so far. Full results appear as the scan progresses.
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 lowerQuery = query.toLowerCase();
137
- const r = finder.mixedSearch(lowerQuery, {
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
- const result = buildResult(r.value.items, lowerQuery);
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: (...args: any[]) => {
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.apply(orig, args);
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, execFileSync } from "node:child_process";
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
- try {
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 exists = options.exists ?? fs.existsSync;
148
- const fromPath = probePath();
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
- const ready = sk.declareDependency({
346
- label: "wakatime-cli",
347
- module: "wakatime",
348
- check: () => findWakatimeCli() !== null,
349
- hint: "Install wakatime-cli to track coding activity.",
350
- });
351
- if (!apiKey || !cliPath || !ready) return;
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