@saifymatteo/pi-extensions 0.0.0-stage → 0.1.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 (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,754 @@
1
+ // zvec — Zvec-Grep adapter for pi
2
+ //
3
+ // Independent pi extension: gives the main agent exact, lexical, and semantic
4
+ // search over a workspace's code and documents through the `zg` CLI
5
+ // (npm i -g @zvec/zvec-grep; CLI docs: https://zvec.org/en/docs/zvec-grep/).
6
+ //
7
+ // This file is self-contained on purpose: it never references, imports, or
8
+ // coordinates with any other extension. Routing is expressed only through this
9
+ // tool's own description/promptGuidelines.
10
+ //
11
+ // Provides:
12
+ // - zvec_search tool : one tool, routed by a `mode` argument:
13
+ // exact → zg query --rg (managed ripgrep; no index/model)
14
+ // fts → BM25 lexical ranking
15
+ // vector → semantic-only
16
+ // hybrid/auto → positional hybrid query
17
+ // - /zvec-init /zvec-sync /zvec-status
18
+ // - session_start : CLI check (report once if missing), config setup,
19
+ // index discovery by walking up from cwd for .zvec-grep/,
20
+ // startup sync when indexed, auto-index when not (bounded
21
+ // by home dir / filesystem root)
22
+ // - agent_settled : single-flight incremental `zg index` after each completed
23
+ // turn (at most one queued trailing sync)
24
+ // - Footer status : "Zvec: init | sync | synced HH:MM | failed" — empty when inactive
25
+ // - Errors : background init/sync notify only after retries are exhausted;
26
+ // manual errors and missing CLI notify immediately. Never saved
27
+ // to session history or added to model context
28
+ // - Retries : background init/sync retries after 5s, 15s, 30s; then
29
+ // paused until a successful manual init/sync or new session
30
+ // - Usage log : one TSV line per search in <agent-home>/zvec-usage.log
31
+ //
32
+ // Config (merged, later wins):
33
+ // 1. Global : $PI_CODING_AGENT_DIR/extensions/zvec.json (fallback ~/.pi/agent/extensions/zvec.json)
34
+ // — auto-created with defaults when missing.
35
+ // 2. Project: <project-root>/.pi/zvec.json — optional per-project overrides.
36
+ // Keys: { "autoInit": boolean, "syncOnSettle": boolean, "embedding": string }
37
+ // Edits are picked up on /reload or the next session start.
38
+ //
39
+ // Failure detection: non-zero exit, timeout, or output matching FAILURE_RE —
40
+ // exit codes are the primary signal and text matching is defensive. The
41
+ // success summary contains a literal "0 failed" counter, so per-file failure
42
+ // counts are stripped before testing; otherwise every successful index reads
43
+ // as a failure.
44
+ //
45
+ // Notes:
46
+ // - Background ops (auto-index, startup sync, settle sync) only spawn when
47
+ // ctx.hasUI (TUI/RPC). In print/JSON mode pi can exit mid-write. Config
48
+ // setup and command-driven ops still happen in all modes.
49
+ // - The startup sync targets the index owner found by walking up from cwd,
50
+ // not cwd itself, so subdirectories of an indexed repo sync the right index.
51
+ // - Auto-index is skipped when cwd IS the home dir or a filesystem root.
52
+ // - No MCP, no daemon: every call shells out to `zg`, which runs in-process
53
+ // in its default `auto` mode (uses a server only if one is already running).
54
+
55
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
56
+ import { Type } from "typebox";
57
+ import { appendFileSync, existsSync, realpathSync } from "node:fs";
58
+ import { execSync } from "node:child_process";
59
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
60
+ import { homedir } from "node:os";
61
+ import * as path from "node:path";
62
+
63
+ const INSTALL_HINT = "zg CLI not found on PATH. Install: npm i -g @zvec/zvec-grep";
64
+
65
+ const STATUS_KEY = "zvec";
66
+ const CLI_CHECK_TIMEOUT_MS = 10_000;
67
+ const RETRY_DELAYS_MS = [5_000, 15_000, 30_000];
68
+ const SEARCH_TIMEOUT_MS = 120_000;
69
+ const SYNC_TIMEOUT_MS = 30 * 60_000; // incremental index can still be large on first catch-up
70
+ const INIT_TIMEOUT_MS = 30 * 60_000; // first index downloads the embedding model
71
+ const STATUS_TIMEOUT_MS = 30_000;
72
+ const FAILURE_RE = /✗|not initialized|no index|no such index|failed/i;
73
+ const FAILED_COUNT_RE = /\b0\s+failed\b/gi;
74
+
75
+ // zg's success summary ends with e.g. "0 failed"; a bare /failed/ match would
76
+ // flag every successful run. Drop only the zero counter (a nonzero "N failed"
77
+ // is a real failure and must survive). Exported for testing.
78
+ export function hasFailureText(out: string): boolean {
79
+ return FAILURE_RE.test(out.replace(FAILED_COUNT_RE, ""));
80
+ }
81
+ const NO_INDEX_RE = /not initialized|no index|no such index/i;
82
+
83
+ export type SearchMode = "auto" | "hybrid" | "fts" | "vector" | "exact";
84
+
85
+ export interface SearchParams {
86
+ query: string;
87
+ mode?: SearchMode;
88
+ path?: string;
89
+ globs?: string[];
90
+ fileTypes?: string[];
91
+ limit?: number;
92
+ }
93
+
94
+ interface ZvecConfig {
95
+ autoInit: boolean;
96
+ syncOnSettle: boolean;
97
+ embedding: string;
98
+ }
99
+
100
+ interface ProjectState {
101
+ config: ZvecConfig;
102
+ projectRoot: string;
103
+ indexed: boolean;
104
+ }
105
+
106
+ const DEFAULT_EMBEDDING = "local/potion-code-16m-v2";
107
+ const DEFAULTS: ZvecConfig = {
108
+ autoInit: true,
109
+ syncOnSettle: true,
110
+ embedding: DEFAULT_EMBEDDING,
111
+ };
112
+
113
+ // Session-scoped module state. The factory re-runs (same module object) when
114
+ // pi rebinds extensions for /new, /resume, /fork and /reload, so a generation
115
+ // token invalidates UI writes from ops belonging to a replaced session.
116
+ let sessionGen = 0;
117
+ let opSeq = 0;
118
+ let currentOpToken = 0; // 0 = idle
119
+ let pendingSync = false;
120
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
121
+ let retriesExhausted = false;
122
+ let state: ProjectState | null = null;
123
+ let latestCtx: ExtensionContext | null = null;
124
+ let cliAvailable: boolean | null = null;
125
+ let notifiedMissing = false;
126
+ // Aborted on session_shutdown (/reload, /new, /resume, /fork): kills in-flight
127
+ // zg children so a replaced runtime never leaves an indexer holding zg's lock,
128
+ // and tells awaiting command handlers their ctx/pi are now stale.
129
+ let runtimeAbort = new AbortController();
130
+
131
+ function norm(p: string): string {
132
+ return process.platform === "win32" ? p.toLowerCase() : p;
133
+ }
134
+
135
+ function realSafe(p: string): string {
136
+ try {
137
+ return realpathSync(p);
138
+ } catch {
139
+ return p;
140
+ }
141
+ }
142
+
143
+ function agentHome(): string {
144
+ return process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
145
+ }
146
+
147
+ function globalConfigPath(): string {
148
+ return path.join(agentHome(), "extensions", "zvec.json");
149
+ }
150
+
151
+ function usageLogPath(): string {
152
+ return path.join(agentHome(), "zvec-usage.log");
153
+ }
154
+
155
+ async function readConfigFile(p: string): Promise<Partial<ZvecConfig>> {
156
+ try {
157
+ const parsed = JSON.parse(await readFile(p, "utf8"));
158
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
159
+ return parsed as Partial<ZvecConfig>;
160
+ }
161
+ } catch {
162
+ // missing or invalid file → treated as {}
163
+ }
164
+ return {};
165
+ }
166
+
167
+ async function ensureGlobalConfig(): Promise<void> {
168
+ const p = globalConfigPath();
169
+ if (existsSync(p)) return;
170
+ try {
171
+ await mkdir(path.dirname(p), { recursive: true });
172
+ await writeFile(p, `${JSON.stringify(DEFAULTS, null, 2)}\n`, "utf8");
173
+ } catch {
174
+ // read-only dir etc. → defaults stay in memory only
175
+ }
176
+ }
177
+
178
+ // zg does not write a .gitignore inside its index dir (CodeGraph does for
179
+ // .codegraph/), so create one — otherwise every indexed repo shows index noise
180
+ // in `git status`.
181
+ async function ensureIndexGitignore(root: string): Promise<void> {
182
+ const p = path.join(root, ".zvec-grep", ".gitignore");
183
+ if (existsSync(p)) return;
184
+ try {
185
+ await mkdir(path.dirname(p), { recursive: true });
186
+ await writeFile(p, "*\n", "utf8");
187
+ } catch {
188
+ /* repo may be read-only; never break a session over this */
189
+ }
190
+ }
191
+
192
+ // Per-key override, only when the value is a proper type.
193
+ function boolOr(project: Partial<ZvecConfig>, global: Partial<ZvecConfig>, key: "autoInit" | "syncOnSettle"): boolean {
194
+ if (typeof project[key] === "boolean") return project[key] as boolean;
195
+ if (typeof global[key] === "boolean") return global[key] as boolean;
196
+ return DEFAULTS[key];
197
+ }
198
+
199
+ function embeddingOr(project: Partial<ZvecConfig>, global: Partial<ZvecConfig>): string {
200
+ if (typeof project.embedding === "string" && project.embedding.trim()) return project.embedding.trim();
201
+ if (typeof global.embedding === "string" && global.embedding.trim()) return global.embedding.trim();
202
+ return DEFAULTS.embedding;
203
+ }
204
+
205
+ // Walk up from startDir looking for .zvec-grep/. Returns the dir owning the
206
+ // index. Stops BEFORE home: ~/.zvec-grep is zg's global state dir
207
+ // (ZVEC_GREP_HOME: config, locks, auth), not a workspace index — treating it
208
+ // as one indexes all of ~ and dies on TCC-protected ~/Library files.
209
+ function findIndexRoot(startDir: string): string | null {
210
+ let dir = realSafe(startDir);
211
+ const home = realSafe(homedir());
212
+ for (;;) {
213
+ if (norm(dir) === norm(home)) return null;
214
+ if (existsSync(path.join(dir, ".zvec-grep"))) return dir;
215
+ const parent = path.dirname(dir);
216
+ if (parent === dir) return null; // filesystem root checked, stop
217
+ dir = parent;
218
+ }
219
+ }
220
+
221
+ // True when cwd IS the home dir or a filesystem root (auto-index guard).
222
+ function isHomeOrRoot(dir: string): boolean {
223
+ const real = realSafe(dir);
224
+ if (path.dirname(real) === real) return true;
225
+ return norm(real) === norm(realSafe(homedir()));
226
+ }
227
+
228
+ function combined(r: { stdout: string; stderr: string }): string {
229
+ return `${r.stdout}\n${r.stderr}`.trim();
230
+ }
231
+
232
+ function outputOf(result: { stdout: string; stderr: string; code: number }): string {
233
+ return combined(result) || `exit ${result.code}`;
234
+ }
235
+
236
+ function hhmm(): string {
237
+ const d = new Date();
238
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
239
+ }
240
+
241
+ function textResult(text: string) {
242
+ return { content: [{ type: "text" as const, text }], details: undefined };
243
+ }
244
+
245
+ // On Windows `zg` is a `.cmd` shim. Reparsing the command line through cmd.exe
246
+ // (and again through the shim's `%*`) misinterprets shell metacharacters, so a
247
+ // regex like `a|b` becomes a pipe and the query is destroyed. Resolve the
248
+ // package's JS entry once and drive it with `node` directly: no shell involved,
249
+ // every argument preserved verbatim.
250
+ let zgJsEntry: string | null | undefined;
251
+ function resolveZgJsEntry(): string | null {
252
+ if (zgJsEntry !== undefined) return zgJsEntry;
253
+ zgJsEntry = null;
254
+ try {
255
+ const root = execSync("npm root -g", {
256
+ encoding: "utf8",
257
+ timeout: CLI_CHECK_TIMEOUT_MS,
258
+ stdio: ["ignore", "pipe", "ignore"],
259
+ }).trim();
260
+ const entry = path.join(root, "@zvec", "zvec-grep", "dist", "cli", "index.js");
261
+ if (existsSync(entry)) zgJsEntry = entry;
262
+ } catch {
263
+ /* no npm / no global install: fall back to the shell launcher */
264
+ }
265
+ return zgJsEntry;
266
+ }
267
+
268
+ async function execZg(
269
+ pi: ExtensionAPI,
270
+ args: string[],
271
+ options: { signal?: AbortSignal; timeout?: number; cwd?: string } = {},
272
+ ) {
273
+ if (process.platform === "win32") {
274
+ const entry = resolveZgJsEntry();
275
+ if (entry) {
276
+ try {
277
+ return await pi.exec("node", [entry, ...args], options);
278
+ } catch {
279
+ /* node missing from PATH: fall back to the shim below */
280
+ }
281
+ }
282
+ // Fallback only: the cmd shim re-parses the command line, so arguments
283
+ // containing shell metacharacters (`|`, `&`, `<`, `>`, `%`, `^`) are not
284
+ // safe here. The node-entry path above exists precisely to avoid this.
285
+ return await pi.exec("cmd.exe", ["/d", "/s", "/c", "zg", ...args], options);
286
+ }
287
+ return await pi.exec("zg", args, options);
288
+ }
289
+
290
+ // Build the zg argv for one search. Pure so it can be unit-tested.
291
+ export function buildSearchArgs(p: SearchParams): string[] {
292
+ const mode: SearchMode = p.mode ?? "auto";
293
+ if (mode === "exact") {
294
+ // Managed ripgrep: no index and no embedding model required.
295
+ const args = ["query", "--rg", "-n", p.query];
296
+ for (const g of p.globs ?? []) args.push("-g", g);
297
+ for (const t of p.fileTypes ?? []) args.push("-t", t);
298
+ return args;
299
+ }
300
+ const args = ["query"];
301
+ if (mode === "fts") args.push("--fts", p.query);
302
+ else if (mode === "vector") args.push("--vector", p.query);
303
+ else args.push(p.query); // auto/hybrid: positional query = hybrid
304
+ if (typeof p.limit === "number" && p.limit > 0) args.push("--limit", String(p.limit));
305
+ for (const g of p.globs ?? []) args.push("-g", g);
306
+ for (const t of p.fileTypes ?? []) args.push("-t", t);
307
+ return args;
308
+ }
309
+
310
+ // Pull the freshness label and hit count out of zg's compact output.
311
+ export function summarizeOutput(out: string): { hits: number; fresh: "fresh" | "possibly_stale" | "unknown" } {
312
+ const fresh: "fresh" | "possibly_stale" | "unknown" = /freshness:\s*possibly_stale/i.test(out)
313
+ ? "possibly_stale"
314
+ : /freshness:\s*fresh/i.test(out)
315
+ ? "fresh"
316
+ : "unknown";
317
+ // Indexed output prints `hits: N`; managed rg prints `path` then per-line
318
+ // matches, so fall back to counting those when no summary line is present.
319
+ const summary = out.match(/^hits:\s*(\d+)/m);
320
+ const hits = summary
321
+ ? Number(summary[1])
322
+ : (out.match(/^(?:[^\s:]+:\d+|\s*\d+[:-])/gm) ?? []).length;
323
+ return { hits, fresh };
324
+ }
325
+
326
+ function sanitizeField(v: string): string {
327
+ return v.replace(/[\t\r\n]+/g, " ").slice(0, 300);
328
+ }
329
+
330
+ export function formatUsageLine(entry: {
331
+ iso: string;
332
+ sessionId: string;
333
+ cwd: string;
334
+ mode: string;
335
+ query: string;
336
+ hits: number;
337
+ ms: number;
338
+ fresh: string;
339
+ code: number;
340
+ }): string {
341
+ return [
342
+ entry.iso,
343
+ sanitizeField(entry.sessionId),
344
+ sanitizeField(entry.cwd),
345
+ entry.mode,
346
+ sanitizeField(entry.query),
347
+ String(entry.hits),
348
+ String(entry.ms),
349
+ entry.fresh,
350
+ `exit=${entry.code}`,
351
+ ].join("\t");
352
+ }
353
+
354
+ function logUsage(line: string): void {
355
+ try {
356
+ appendFileSync(usageLogPath(), `${line}\n`);
357
+ } catch {
358
+ /* never break a search over logging */
359
+ }
360
+ }
361
+
362
+ function sessionIdOf(ctx: ExtensionContext): string {
363
+ try {
364
+ const sm = (ctx as unknown as { sessionManager?: { getSessionId?: () => string } }).sessionManager;
365
+ return sm?.getSessionId?.() ?? "";
366
+ } catch {
367
+ return "";
368
+ }
369
+ }
370
+
371
+ export default function zvec(pi: ExtensionAPI) {
372
+ // ── shared CLI availability check ─────────────────────────────────────
373
+ async function ensureCli(pi2: ExtensionAPI): Promise<boolean> {
374
+ if (cliAvailable !== null) return cliAvailable;
375
+ try {
376
+ const r = await execZg(pi2, ["version"], { timeout: CLI_CHECK_TIMEOUT_MS });
377
+ cliAvailable = r.code === 0;
378
+ } catch {
379
+ cliAvailable = false;
380
+ }
381
+ return cliAvailable;
382
+ }
383
+
384
+ // ── UI helpers: drop writes from a replaced session, never crash pi ───
385
+ function status(ctx: ExtensionContext, gen: number, text: string | undefined) {
386
+ if (gen !== sessionGen) return;
387
+ try {
388
+ ctx.ui.setStatus(STATUS_KEY, text);
389
+ } catch {
390
+ /* replaced session */
391
+ }
392
+ }
393
+
394
+ function reportError(ctx: ExtensionContext, gen: number, title: string, detail = "") {
395
+ if (gen !== sessionGen || !ctx.hasUI) return;
396
+ try {
397
+ ctx.ui.notify(detail ? `${title}\n${detail}` : title, "error");
398
+ } catch {
399
+ /* replaced session */
400
+ }
401
+ }
402
+
403
+ // A waiting retry owns the single-flight slot, just like a running child.
404
+ function cancelRetry() {
405
+ if (retryTimer === null) return;
406
+ clearTimeout(retryTimer);
407
+ retryTimer = null;
408
+ currentOpToken = 0;
409
+ pendingSync = false;
410
+ }
411
+
412
+ // ── background ops (init/sync): bounded retries + trailing sync ─────────
413
+ function spawnOp(kind: "init" | "sync", ctx: ExtensionContext, gen: number, target: string, attempt = 0) {
414
+ const token = ++opSeq;
415
+ currentOpToken = token;
416
+ const embedding = state?.config.embedding ?? DEFAULT_EMBEDDING;
417
+ const args = kind === "init" ? ["index", target, "--embedding", embedding] : ["index", target];
418
+ const timeout = kind === "init" ? INIT_TIMEOUT_MS : SYNC_TIMEOUT_MS;
419
+ status(ctx, gen, kind === "init" ? "Zvec: init" : "Zvec: sync");
420
+
421
+ void (async () => {
422
+ const started = Date.now();
423
+ let failure: string | null = null;
424
+ let detail = "";
425
+ try {
426
+ const r = await execZg(pi, args, { timeout, cwd: target, signal: runtimeAbort.signal });
427
+ const out = combined(r);
428
+ if (r.killed) {
429
+ failure = `Zvec ${kind} timed out (${Math.round(timeout / 60_000)}m)`;
430
+ detail = out;
431
+ } else if (r.code !== 0 || hasFailureText(out)) {
432
+ failure = `Zvec ${kind} failed (exit ${r.code})`;
433
+ detail = out || "(no output)";
434
+ }
435
+ } catch (err) {
436
+ failure = `Zvec ${kind} failed`;
437
+ detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
438
+ }
439
+
440
+ if (gen !== sessionGen || currentOpToken !== token) return;
441
+ const secs = Math.round((Date.now() - started) / 1000);
442
+ if (failure) {
443
+ const delay = RETRY_DELAYS_MS[attempt];
444
+ if (delay !== undefined) {
445
+ status(ctx, gen, `Zvec: retry ${attempt + 1}/3 in ${delay / 1000}s`);
446
+ retryTimer = setTimeout(() => {
447
+ if (gen !== sessionGen || currentOpToken !== token) return;
448
+ retryTimer = null;
449
+ spawnOp(kind, ctx, gen, target, attempt + 1);
450
+ }, delay);
451
+ return;
452
+ }
453
+ // Recovered retries leave no notification; report only the final failure.
454
+ reportError(ctx, gen, failure, detail);
455
+ retriesExhausted = true;
456
+ pendingSync = false;
457
+ status(ctx, gen, "Zvec: failed");
458
+ } else {
459
+ if (state && norm(state.projectRoot) === norm(target)) {
460
+ state.indexed = true;
461
+ }
462
+ if (kind === "init") await ensureIndexGitignore(target);
463
+ if (gen !== sessionGen) return;
464
+ status(ctx, gen, kind === "init" ? `Zvec: indexed in ${secs}s` : `Zvec: synced ${hhmm()}`);
465
+ }
466
+ if (currentOpToken === token) currentOpToken = 0;
467
+ maybeTrailing();
468
+ })();
469
+ }
470
+
471
+ function maybeTrailing() {
472
+ if (!pendingSync || currentOpToken !== 0) return;
473
+ pendingSync = false;
474
+ if (retriesExhausted || !state?.indexed || !state.config.syncOnSettle || !latestCtx) return;
475
+ spawnOp("sync", latestCtx, sessionGen, state.projectRoot);
476
+ }
477
+
478
+ // ── zvec_search tool ───────────────────────────────────────────────────
479
+ pi.registerTool({
480
+ name: "zvec_search",
481
+ label: "Zvec Search",
482
+ description:
483
+ "Searches workspace code and text, returning compact, source-linked evidence: exact literal/regex " +
484
+ "(managed ripgrep), BM25 keyword ranking, and semantic/vector + hybrid retrieval.",
485
+ promptSnippet:
486
+ "Exact, ranked, and semantic workspace search (mode: auto|hybrid|fts|vector|exact)",
487
+ promptGuidelines: [
488
+ "Use zvec_search when the wording or location is unknown (semantic/hybrid), to rank known terms (fts), or for an exact literal or regex (mode:'exact').",
489
+ "The workspace index auto-builds and auto-syncs; /zvec-init forces a rebuild. mode:'exact' runs without the index.",
490
+ ],
491
+ parameters: Type.Object({
492
+ query: Type.String({
493
+ description: "The search text: a natural-language question, keywords, or (mode:'exact') a literal/regex",
494
+ }),
495
+ mode: Type.Optional(
496
+ Type.Union(
497
+ [
498
+ Type.Literal("auto"),
499
+ Type.Literal("hybrid"),
500
+ Type.Literal("fts"),
501
+ Type.Literal("vector"),
502
+ Type.Literal("exact"),
503
+ ],
504
+ {
505
+ description:
506
+ "auto/hybrid = lexical+semantic fusion (default); fts = BM25 keyword ranking; vector = semantic only; exact = managed ripgrep (literal/regex, no index)",
507
+ },
508
+ ),
509
+ ),
510
+ path: Type.Optional(
511
+ Type.String({ description: "Workspace root to search; defaults to the nearest indexed root from cwd" }),
512
+ ),
513
+ globs: Type.Optional(
514
+ Type.Array(Type.String(), { description: "Include/exclude path globs (prefix ! to exclude)" }),
515
+ ),
516
+ fileTypes: Type.Optional(Type.Array(Type.String(), { description: "File-type filters, e.g. ['ts']" })),
517
+ limit: Type.Optional(Type.Integer({ description: "Maximum results per group" })),
518
+ }),
519
+ execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
520
+ if (!(await ensureCli(pi))) return textResult(INSTALL_HINT);
521
+ const root = params.path ? path.resolve(params.path) : (findIndexRoot(ctx.cwd) ?? ctx.cwd);
522
+ const mode: SearchMode = params.mode ?? "auto";
523
+ const args = buildSearchArgs({
524
+ query: params.query,
525
+ mode,
526
+ path: params.path,
527
+ globs: params.globs,
528
+ fileTypes: params.fileTypes,
529
+ limit: params.limit,
530
+ });
531
+ const started = Date.now();
532
+ let result: Awaited<ReturnType<typeof execZg>>;
533
+ try {
534
+ result = await execZg(pi, args, { signal, timeout: SEARCH_TIMEOUT_MS, cwd: root });
535
+ } catch (err) {
536
+ return textResult(err instanceof Error ? err.message : String(err));
537
+ }
538
+ const ms = Date.now() - started;
539
+ const out = result.stdout.trim() || combined(result) || `exit ${result.code}`;
540
+ const { hits, fresh } = summarizeOutput(out);
541
+ logUsage(
542
+ formatUsageLine({
543
+ iso: new Date().toISOString(),
544
+ sessionId: sessionIdOf(ctx),
545
+ cwd: root,
546
+ mode,
547
+ query: params.query,
548
+ hits,
549
+ ms,
550
+ fresh,
551
+ code: result.code,
552
+ }),
553
+ );
554
+ if (result.killed) return textResult("zvec search timed out (120s)");
555
+ if (result.code !== 0) {
556
+ const failOut = outputOf(result);
557
+ if (mode !== "exact" && NO_INDEX_RE.test(failOut)) {
558
+ return textResult(
559
+ `${failOut}\n\nThis workspace has no Zvec index yet. Build one with /zvec-init ` +
560
+ `(or run: zg index "${root}"), or use mode:'exact' for index-free search.`,
561
+ );
562
+ }
563
+ return textResult(failOut);
564
+ }
565
+ return textResult(out);
566
+ },
567
+ });
568
+
569
+ // Command exceptions use the same non-persistent notification as CLI failures.
570
+ function registerCommand(name: string, command: Parameters<ExtensionAPI["registerCommand"]>[1]) {
571
+ pi.registerCommand(name, {
572
+ ...command,
573
+ handler: async (args, ctx) => {
574
+ const gen = sessionGen;
575
+ const indexes = name === "zvec-init" || name === "zvec-sync";
576
+ // A manual operation can replace a delayed retry, not a live writer.
577
+ if (indexes && currentOpToken !== 0 && retryTimer === null) {
578
+ if (ctx.hasUI) ctx.ui.notify(`Zvec: indexing already in progress. Try /${name} again when it finishes.`, "info");
579
+ return;
580
+ }
581
+ if (indexes) cancelRetry();
582
+ const token = indexes ? ++opSeq : 0;
583
+ if (token) currentOpToken = token;
584
+ try {
585
+ await command.handler(args, ctx);
586
+ } catch (err) {
587
+ reportError(ctx, gen, `Zvec command /${name} failed`, err instanceof Error ? (err.stack ?? err.message) : String(err));
588
+ } finally {
589
+ if (token && gen === sessionGen && currentOpToken === token) {
590
+ currentOpToken = 0;
591
+ maybeTrailing();
592
+ }
593
+ }
594
+ },
595
+ });
596
+ }
597
+
598
+ // ── /zvec-init ─────────────────────────────────────────────────────────
599
+ registerCommand("zvec-init", {
600
+ description: "Build the Zvec index for the current project (zg index)",
601
+ handler: async (args, ctx) => {
602
+ const sig = runtimeAbort.signal;
603
+ const target = args?.trim() || ctx.cwd;
604
+ if (!(await ensureCli(pi))) {
605
+ if (!sig.aborted) reportError(ctx, sessionGen, INSTALL_HINT);
606
+ return;
607
+ }
608
+ if (sig.aborted) return;
609
+ const embedding = state?.config.embedding ?? DEFAULT_EMBEDDING;
610
+ if (ctx.hasUI) {
611
+ ctx.ui.notify(`Indexing ${target} with ${embedding} — first run downloads the model…`, "info");
612
+ }
613
+ const result = await execZg(pi, ["index", target, "--embedding", embedding], {
614
+ timeout: INIT_TIMEOUT_MS,
615
+ cwd: target,
616
+ signal: sig,
617
+ });
618
+ if (sig.aborted) return; // reloaded mid-run: ctx and pi are stale
619
+ const out = outputOf(result);
620
+ const success = !result.killed && result.code === 0 && !hasFailureText(out);
621
+ if (success) retriesExhausted = false;
622
+ if (success && state) {
623
+ state.projectRoot = target;
624
+ state.indexed = true;
625
+ }
626
+ if (success) await ensureIndexGitignore(target);
627
+ if (sig.aborted) return;
628
+ if (!success) {
629
+ reportError(ctx, sessionGen, result.killed ? "Zvec init timed out" : `Zvec init failed (exit ${result.code})`, out);
630
+ return;
631
+ }
632
+ if (ctx.hasUI) ctx.ui.notify("Zvec index built.", "info");
633
+ pi.sendMessage({ customType: "zvec-init", content: out, display: true }, { triggerTurn: false });
634
+ },
635
+ });
636
+
637
+ // ── /zvec-sync ─────────────────────────────────────────────────────────
638
+ registerCommand("zvec-sync", {
639
+ description: "Update the Zvec index for new/changed files (zg index)",
640
+ handler: async (args, ctx) => {
641
+ const sig = runtimeAbort.signal;
642
+ const target = args?.trim() || state?.projectRoot || ctx.cwd;
643
+ if (!(await ensureCli(pi))) {
644
+ if (!sig.aborted) reportError(ctx, sessionGen, INSTALL_HINT);
645
+ return;
646
+ }
647
+ if (sig.aborted) return;
648
+ if (ctx.hasUI) ctx.ui.notify(`Syncing Zvec for ${target}…`, "info");
649
+ const result = await execZg(pi, ["index", target], { timeout: SYNC_TIMEOUT_MS, cwd: target, signal: sig });
650
+ if (sig.aborted) return; // reloaded mid-run: ctx and pi are stale
651
+ const out = outputOf(result);
652
+ const success = !result.killed && result.code === 0 && !hasFailureText(out);
653
+ if (success) retriesExhausted = false;
654
+ if (success && state) state.indexed = true;
655
+ if (!success) {
656
+ reportError(ctx, sessionGen, result.killed ? "Zvec sync timed out" : `Zvec sync failed (exit ${result.code})`, out);
657
+ return;
658
+ }
659
+ if (ctx.hasUI) ctx.ui.notify("Zvec synced.", "info");
660
+ pi.sendMessage({ customType: "zvec-sync", content: out, display: true }, { triggerTurn: false });
661
+ },
662
+ });
663
+
664
+ // ── /zvec-status ───────────────────────────────────────────────────────
665
+ registerCommand("zvec-status", {
666
+ description: "Show Zvec index status and statistics",
667
+ handler: async (args, ctx) => {
668
+ const sig = runtimeAbort.signal;
669
+ const target = args?.trim() || state?.projectRoot || ctx.cwd;
670
+ if (!(await ensureCli(pi))) {
671
+ if (!sig.aborted) reportError(ctx, sessionGen, INSTALL_HINT);
672
+ return;
673
+ }
674
+ if (sig.aborted) return;
675
+ const result = await execZg(pi, ["status", target], { timeout: STATUS_TIMEOUT_MS, signal: sig });
676
+ if (sig.aborted) return; // reloaded mid-run: ctx and pi are stale
677
+ const out = outputOf(result);
678
+ if (result.code !== 0 || result.killed || hasFailureText(out)) {
679
+ reportError(ctx, sessionGen, result.killed ? "Zvec status timed out" : `Zvec status failed (exit ${result.code})`, out);
680
+ return;
681
+ }
682
+ if (ctx.hasUI) ctx.ui.notify(out, "info");
683
+ pi.sendMessage({ customType: "zvec-status", content: out, display: true }, { triggerTurn: false });
684
+ },
685
+ });
686
+
687
+ // ── session_start: CLI check → config → auto-index / startup sync ─────
688
+ // ── session_shutdown: kill in-flight zg children, invalidate UI writes ──
689
+ pi.on("session_shutdown", () => {
690
+ cancelRetry();
691
+ runtimeAbort.abort();
692
+ sessionGen++;
693
+ currentOpToken = 0;
694
+ pendingSync = false;
695
+ latestCtx = null;
696
+ });
697
+
698
+ pi.on("session_start", async (_event, ctx) => {
699
+ runtimeAbort.abort();
700
+ runtimeAbort = new AbortController();
701
+ const gen = ++sessionGen;
702
+ cancelRetry();
703
+ retriesExhausted = false;
704
+ state = null;
705
+ latestCtx = ctx;
706
+ currentOpToken = 0; // invalidate any op from a replaced session
707
+ pendingSync = false;
708
+ status(ctx, gen, undefined);
709
+
710
+ if (!(await ensureCli(pi))) {
711
+ if (gen !== sessionGen) return;
712
+ if (ctx.hasUI && !notifiedMissing) {
713
+ notifiedMissing = true;
714
+ reportError(ctx, gen, INSTALL_HINT);
715
+ }
716
+ return;
717
+ }
718
+
719
+ await ensureGlobalConfig();
720
+ const indexedRoot = findIndexRoot(ctx.cwd);
721
+ const projectRoot = indexedRoot ?? ctx.cwd;
722
+ const globalCfg = await readConfigFile(globalConfigPath());
723
+ const projectCfg = await readConfigFile(path.join(projectRoot, ".pi", "zvec.json"));
724
+ const config: ZvecConfig = {
725
+ autoInit: boolOr(projectCfg, globalCfg, "autoInit"),
726
+ syncOnSettle: boolOr(projectCfg, globalCfg, "syncOnSettle"),
727
+ embedding: embeddingOr(projectCfg, globalCfg),
728
+ };
729
+ if (gen !== sessionGen) return;
730
+ state = { config, projectRoot, indexed: indexedRoot !== null };
731
+
732
+ if (indexedRoot !== null) {
733
+ await ensureIndexGitignore(indexedRoot);
734
+ // Startup sync on the index owner (not cwd — cwd may be a subdir).
735
+ if (gen === sessionGen && ctx.hasUI) spawnOp("sync", ctx, gen, projectRoot);
736
+ } else if (config.autoInit && ctx.hasUI && !isHomeOrRoot(ctx.cwd)) {
737
+ spawnOp("init", ctx, gen, ctx.cwd);
738
+ }
739
+ });
740
+
741
+ // ── agent_settled: single-flight incremental index after each turn ────
742
+ pi.on("agent_settled", (_event, ctx) => {
743
+ latestCtx = ctx;
744
+ // Print/JSON mode: pi exits after prompts; a fire-and-forget child
745
+ // could be killed mid-write.
746
+ if (!ctx.hasUI) return;
747
+ if (retriesExhausted || !state?.indexed || !state.config.syncOnSettle) return;
748
+ if (currentOpToken !== 0) {
749
+ pendingSync = true; // at most one trailing sync
750
+ return;
751
+ }
752
+ spawnOp("sync", ctx, sessionGen, state.projectRoot);
753
+ });
754
+ }