pi-piqo 0.0.1

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 ADDED
@@ -0,0 +1,91 @@
1
+ # Piqo — Pi Extension
2
+
3
+ A chat-less way to collaborate with your favorite LLM models (remote or local), directly from your files and from any editor. The output is stored automatically into your files.
4
+
5
+ It is a simple file-watcher extension for [pi](https://github.com/badlogic/pi-mono) triggered on-save, which monitors directories for `@piqo` markers and uses the LLM to generate content inline.
6
+
7
+ Might be useful for keeping notes, writing tasks, researching topics, etc.
8
+
9
+ Random Example:
10
+ If you write the following (highlighted line) and save the file:
11
+ <img width="719" height="160" alt="Image" src="https://github.com/user-attachments/assets/30eda1ca-34ad-468a-baef-52db15169573" />
12
+
13
+ You will get something like (using gpt-5.4-mini):
14
+
15
+ <img width="718" height="267" alt="Image" src="https://github.com/user-attachments/assets/8f0c9a27-c366-4022-8e27-e188a0e19188" />
16
+
17
+ ## How It Works
18
+
19
+ 1. You start pi with the piqo extension and specify directories to watch
20
+ 2. Piqo recursively watches those directories for file changes
21
+ 3. When a file contains one or more `@piqo <instruction>` markers, it reads the file, gathers context around all markers, and sends them to pi's LLM in one request
22
+ 4. The LLM fulfills each prompt and removes the human prompt line/tag from the file
23
+
24
+ ## Usage
25
+ If you have [Pi](https://pi.dev/) installed, you are one command away from using it:
26
+
27
+ ```bash
28
+ # Load it directly from github
29
+ pi -e https://github.com/piqoni/piqo-extension --dir=/path/to/your/project
30
+
31
+ # Or if you want to reference it locally, git clone the repo and reference it directly
32
+ pi -e ./piqo-extension --dir /path/to/your/project
33
+
34
+ # Watch multiple directories
35
+ pi -e ./piqo-extension --dir /path/to/dir1,/path/to/dir2
36
+
37
+ # Headless mode (no TUI)
38
+ pi -e ./piqo-extension --dir /path/to/project -p "Start piqo watcher"
39
+ ```
40
+
41
+ ## Marker Format
42
+
43
+ In any text file within the watched directories, add:
44
+
45
+ ```
46
+ @piqo <your instruction here>
47
+ ```
48
+
49
+ The LLM will process it and replace/remove the prompt so the file becomes:
50
+
51
+ ```
52
+ ... generated content ...
53
+ ```
54
+
55
+ ### Examples
56
+
57
+ **In a Python file:**
58
+ ```python
59
+ # @piqo add a function to parse CSV files and return a list of dicts
60
+
61
+ # Becomes generated code with the @piqo prompt removed
62
+ ```
63
+
64
+ **In a Markdown file:**
65
+ ```markdown
66
+ @piqo write a summary of REST API best practices
67
+
68
+ Becomes generated content with the @piqo prompt removed
69
+ ```
70
+
71
+ **In a config file:**
72
+ ```yaml
73
+ # @piqo add sensible default nginx config for a Node.js app
74
+
75
+ # Becomes generated config with the @piqo prompt removed
76
+ ```
77
+
78
+ ## Behavior Details
79
+
80
+ - **Debounce**: File changes are debounced at 500ms per file to avoid duplicate processing
81
+ - **Initial scan**: On startup, piqo scans all watched directories for existing markers
82
+ - **Ignored paths**: Hidden files/dirs, `node_modules`, `.git` are automatically skipped
83
+ - **Text files only**: Only processes common text file extensions (.ts, .js, .py, .md, .txt, etc.)
84
+
85
+ ## Installation
86
+
87
+ Place this extension in `~/.pi/agent/extensions/piqo/` for global access, or reference it directly:
88
+
89
+ ```bash
90
+ pi -e /path/to/piqo-extension
91
+ ```
package/index.ts ADDED
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Piqo Extension
3
+ *
4
+ * Watches directories for file changes. When a file contains one or more
5
+ * @piqo markers, it reads the file, focuses on the @piqo context, and sends
6
+ * it to pi's LLM to generate or modify content. The LLM must remove the
7
+ * human @piqo prompt line/tag as part of its edit, so no /piqo/ closing marker
8
+ * is needed.
9
+ *
10
+ * Usage:
11
+ * pi -e ./piqo-extension --dir /path/to/dir1,/path/to/dir2
12
+ *
13
+ * In headless (print) mode:
14
+ * pi -e ./piqo-extension --dir /path/to/dir1 -p "Start piqo watcher"
15
+ *
16
+ * Marker format:
17
+ * @piqo <instruction here>
18
+ * ... LLM replaces the prompt with generated content and removes @piqo ...
19
+ */
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
24
+
25
+ // Track which files are currently being processed to avoid duplicate agent runs
26
+ interface PiqoMarker {
27
+ filePath: string;
28
+ lineNumber: number;
29
+ instruction: string;
30
+ lineText: string;
31
+ }
32
+
33
+ const PIQO_PROMPT_SENTINEL = "[piqo-request]";
34
+
35
+ function getMessageText(message: any): string {
36
+ const content = message?.content;
37
+ if (typeof content === "string") return content;
38
+ if (!Array.isArray(content)) return "";
39
+
40
+ return content
41
+ .map((part) => {
42
+ if (typeof part === "string") return part;
43
+ if (part?.type === "text" && typeof part.text === "string") return part.text;
44
+ return "";
45
+ })
46
+ .join("\n");
47
+ }
48
+
49
+ export default function (pi: ExtensionAPI) {
50
+ // Register the --dir flag
51
+ pi.registerFlag("dir", {
52
+ description: "Comma-separated directories to watch for @piqo markers",
53
+ type: "string",
54
+ default: "",
55
+ });
56
+
57
+ const processing = new Set<string>(); // file paths currently being processed
58
+ const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-file debounce
59
+ const watchers: fs.FSWatcher[] = [];
60
+
61
+ // Isolate Piqo LLM calls from prior chat/session history.
62
+ // We keep messages from the current Piqo request onward so tool-call loops
63
+ // still work, but older user/assistant turns cannot affect the request.
64
+ pi.on("context", async (event) => {
65
+ let piqoStartIdx = -1;
66
+ for (let i = event.messages.length - 1; i >= 0; i--) {
67
+ const message = event.messages[i];
68
+ if (message.role === "user" && getMessageText(message).includes(PIQO_PROMPT_SENTINEL)) {
69
+ piqoStartIdx = i;
70
+ break;
71
+ }
72
+ }
73
+
74
+ if (piqoStartIdx === -1) return;
75
+ return { messages: event.messages.slice(piqoStartIdx) };
76
+ });
77
+
78
+ /**
79
+ * Scan a file for @piqo markers. Markers are considered pending until the
80
+ * agent removes the human prompt line/tag from the file.
81
+ */
82
+ function findMarkers(filePath: string): PiqoMarker[] {
83
+ let content: string;
84
+ try {
85
+ content = fs.readFileSync(filePath, "utf-8");
86
+ } catch {
87
+ return [];
88
+ }
89
+
90
+ const lines = content.split("\n");
91
+ const markers: PiqoMarker[] = [];
92
+
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const line = lines[i];
95
+ const piqoMatch = line.match(/@piqo\b(.*)/);
96
+ if (!piqoMatch) continue;
97
+
98
+ markers.push({
99
+ filePath,
100
+ lineNumber: i + 1,
101
+ instruction: piqoMatch[1].trim(),
102
+ lineText: line,
103
+ });
104
+ }
105
+
106
+ return markers;
107
+ }
108
+
109
+ /**
110
+ * Build context around a @piqo marker for the LLM.
111
+ * Includes surrounding lines for context.
112
+ */
113
+ function buildContext(filePath: string, marker: PiqoMarker): string {
114
+ let content: string;
115
+ try {
116
+ content = fs.readFileSync(filePath, "utf-8");
117
+ } catch {
118
+ return "";
119
+ }
120
+
121
+ const lines = content.split("\n");
122
+ const markerLineIdx = marker.lineNumber - 1;
123
+
124
+ // Get surrounding context (up to 30 lines before and 10 after)
125
+ const contextBefore = Math.max(0, markerLineIdx - 30);
126
+ const contextAfter = Math.min(lines.length, markerLineIdx + 11);
127
+ const surroundingLines = lines.slice(contextBefore, contextAfter);
128
+
129
+ const relativePath = filePath;
130
+ const ext = path.extname(filePath).slice(1) || "txt";
131
+
132
+ return [
133
+ `File: ${relativePath}`,
134
+ `Marker at line ${marker.lineNumber}`,
135
+ `Instruction: ${marker.instruction || "(no specific instruction — infer from context)"}`,
136
+ "",
137
+ `\`\`\`${ext}`,
138
+ ...surroundingLines.map(
139
+ (line, idx) =>
140
+ `${contextBefore + idx + 1 === marker.lineNumber ? ">>> " : " "}${line}`
141
+ ),
142
+ "```",
143
+ ].join("\n");
144
+ }
145
+
146
+ /**
147
+ * Process all @piqo markers in a file in one agent run. This avoids line
148
+ * shifting and concurrent edit conflicts when a file has multiple markers.
149
+ */
150
+ function processFileMarkers(filePath: string, markers: PiqoMarker[]): void {
151
+ if (processing.has(filePath)) return;
152
+ processing.add(filePath);
153
+
154
+ const contexts = markers.map((marker) => buildContext(filePath, marker)).filter(Boolean);
155
+ if (contexts.length === 0) {
156
+ processing.delete(filePath);
157
+ return;
158
+ }
159
+
160
+ const markerList = markers
161
+ .map(
162
+ (marker, idx) =>
163
+ `${idx + 1}. Line ${marker.lineNumber}: ${marker.lineText.trim()}\n Instruction: ${marker.instruction || "(no specific instruction — infer from context)"}`
164
+ )
165
+ .join("\n");
166
+
167
+ const prompt = [
168
+ `${PIQO_PROMPT_SENTINEL} A file has one or more @piqo markers requesting AI assistance. Read the
169
+ file, understand each marker, and fulfill every request in one edit.`,
170
+ "",
171
+ "MARKERS TO PROCESS:",
172
+ markerList,
173
+ "",
174
+ "CONTEXT:",
175
+ contexts.join("\n\n---\n\n"),
176
+ "",
177
+ "INSTRUCTIONS:",
178
+ `1. Read the file "${filePath}" to get the full current content.`,
179
+ "2. For every @piqo marker listed above, generate or modify content as requested by the human prompt after @piqo.",
180
+ "3. CRITICAL: Always remove the human prompt from the file. If @piqo is on its own line or in a comment line, remove that whole prompt line and replace it with the generated content if appropriate.",
181
+ "4. CRITICAL The final file must contain no @piqo tags for the prompts you processed.",
182
+ "5. Keep unrelated content intact and preserve the file's style/formatting.",
183
+ "",
184
+ "Example transformation:",
185
+ " Before:",
186
+ " @piqo add a hello world function",
187
+ " After:",
188
+ " function helloWorld() {",
189
+ ' console.log("Hello, World!");',
190
+ " }",
191
+ ].join("\n");
192
+
193
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
194
+ }
195
+
196
+ /**
197
+ * Handle a file change event: debounce, scan for markers, process.
198
+ */
199
+ function onFileChange(filePath: string): void {
200
+ // Debounce per file: wait 500ms after last change before processing
201
+ const existing = debounceTimers.get(filePath);
202
+ if (existing) clearTimeout(existing);
203
+
204
+ debounceTimers.set(
205
+ filePath,
206
+ setTimeout(() => {
207
+ debounceTimers.delete(filePath);
208
+
209
+ const markers = findMarkers(filePath);
210
+ if (markers.length > 0) {
211
+ processFileMarkers(filePath, markers);
212
+ }
213
+ }, 500)
214
+ );
215
+ }
216
+
217
+ /**
218
+ * Recursively watch a directory using fs.watch with recursive option.
219
+ */
220
+ function watchDirectory(dirPath: string): void {
221
+ const resolvedDir = path.resolve(dirPath);
222
+
223
+ if (!fs.existsSync(resolvedDir)) {
224
+ console.error(`[piqo] Directory does not exist: ${resolvedDir}`);
225
+ return;
226
+ }
227
+
228
+ try {
229
+ const watcher = fs.watch(resolvedDir, { recursive: true }, (eventType, filename) => {
230
+ if (!filename) return;
231
+
232
+ const fullPath = path.join(resolvedDir, filename);
233
+
234
+ // Skip hidden files/dirs, node_modules, .git, etc.
235
+ if (
236
+ filename.startsWith(".") ||
237
+ filename.includes("node_modules") ||
238
+ filename.includes(".git") ||
239
+ filename.includes("/.")
240
+ ) {
241
+ return;
242
+ }
243
+
244
+ // Only process text-like files
245
+ const ext = path.extname(filename).toLowerCase();
246
+ const textExts = new Set([
247
+ ".txt", ".md", ".js", ".ts", ".jsx", ".tsx", ".py", ".rb", ".rs",
248
+ ".go", ".java", ".c", ".cpp", ".h", ".hpp", ".css", ".html", ".xml",
249
+ ".json", ".yaml", ".yml", ".toml", ".sh", ".bash", ".zsh", ".fish",
250
+ ".sql", ".r", ".swift", ".kt", ".scala", ".lua", ".vim", ".el",
251
+ ".clj", ".hs", ".ml", ".ex", ".exs", ".erl", ".dart", ".cs",
252
+ ".php", ".pl", ".pm", ".svelte", ".vue", ".astro", ".mdx",
253
+ ]);
254
+ if (!textExts.has(ext)) return;
255
+
256
+ // Check file exists and is a regular file
257
+ try {
258
+ const stat = fs.statSync(fullPath);
259
+ if (!stat.isFile()) return;
260
+ } catch {
261
+ return; // File may have been deleted
262
+ }
263
+
264
+ onFileChange(fullPath);
265
+ });
266
+
267
+ watchers.push(watcher);
268
+ console.log(`[piqo] Watching directory: ${resolvedDir}`);
269
+ } catch (err) {
270
+ console.error(`[piqo] Failed to watch ${resolvedDir}:`, err);
271
+ }
272
+ }
273
+
274
+ // Clean up on agent_end — remove processing keys for completed markers
275
+ pi.on("agent_end", async (_event, _ctx) => {
276
+ // After the agent finishes a turn, clear the processing set
277
+ // so markers that failed can be retried on next file change
278
+ processing.clear();
279
+ });
280
+
281
+ // Start watching on session_start
282
+ pi.on("session_start", async (_event, ctx) => {
283
+ const dirFlag = pi.getFlag("dir") as string;
284
+ if (!dirFlag) {
285
+ if (ctx.hasUI) {
286
+ ctx.ui.notify("[piqo] No --dir specified. Use --dir=path1,path2 to watch directories.", "warning");
287
+ }
288
+ console.log("[piqo] No --dir specified. Piqo is idle.");
289
+ return;
290
+ }
291
+
292
+ const dirs = dirFlag
293
+ .split(",")
294
+ .map((d) => d.trim())
295
+ .filter(Boolean);
296
+
297
+ if (dirs.length === 0) {
298
+ console.log("[piqo] No valid directories specified.");
299
+ return;
300
+ }
301
+
302
+ for (const dir of dirs) {
303
+ watchDirectory(dir);
304
+ }
305
+
306
+ if (ctx.hasUI) {
307
+ ctx.ui.notify(`[piqo] Watching ${dirs.length} director${dirs.length === 1 ? "y" : "ies"} for @piqo markers`, "info");
308
+ ctx.ui.setStatus("piqo", `👁 Watching ${dirs.length} dir${dirs.length === 1 ? "" : "s"}`);
309
+ }
310
+
311
+ // Do an initial scan of all watched directories
312
+ for (const dir of dirs) {
313
+ const resolvedDir = path.resolve(dir);
314
+ try {
315
+ scanDirectoryRecursive(resolvedDir);
316
+ } catch (err) {
317
+ console.error(`[piqo] Initial scan failed for ${resolvedDir}:`, err);
318
+ }
319
+ }
320
+ });
321
+
322
+ /**
323
+ * Recursively scan a directory for files with @piqo markers.
324
+ */
325
+ function scanDirectoryRecursive(dirPath: string): void {
326
+ let entries: fs.Dirent[];
327
+ try {
328
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
329
+ } catch {
330
+ return;
331
+ }
332
+
333
+ for (const entry of entries) {
334
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
335
+
336
+ const fullPath = path.join(dirPath, entry.name);
337
+ if (entry.isDirectory()) {
338
+ scanDirectoryRecursive(fullPath);
339
+ } else if (entry.isFile()) {
340
+ const ext = path.extname(entry.name).toLowerCase();
341
+ const textExts = new Set([
342
+ ".txt", ".md", ".js", ".ts", ".jsx", ".tsx", ".py", ".rb", ".rs",
343
+ ".go", ".java", ".c", ".cpp", ".h", ".hpp", ".css", ".html", ".xml",
344
+ ".json", ".yaml", ".yml", ".toml", ".sh", ".bash", ".zsh", ".fish",
345
+ ".sql", ".r", ".swift", ".kt", ".scala", ".lua", ".vim", ".el",
346
+ ".clj", ".hs", ".ml", ".ex", ".exs", ".erl", ".dart", ".cs",
347
+ ".php", ".pl", ".pm", ".svelte", ".vue", ".astro", ".mdx",
348
+ ]);
349
+ if (!textExts.has(ext)) continue;
350
+
351
+ // Quick check if file contains @piqo
352
+ try {
353
+ const content = fs.readFileSync(fullPath, "utf-8");
354
+ if (content.includes("@piqo")) {
355
+ onFileChange(fullPath);
356
+ }
357
+ } catch {
358
+ // skip unreadable files
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ // Cleanup watchers on shutdown
365
+ pi.on("session_shutdown", async () => {
366
+ for (const watcher of watchers) {
367
+ try {
368
+ watcher.close();
369
+ } catch {
370
+ // ignore
371
+ }
372
+ }
373
+ watchers.length = 0;
374
+
375
+ for (const timer of debounceTimers.values()) {
376
+ clearTimeout(timer);
377
+ }
378
+ debounceTimers.clear();
379
+ processing.clear();
380
+
381
+ console.log("[piqo] Watchers closed.");
382
+ });
383
+ }
package/package.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "pi-piqo",
3
+ "version": "0.0.1",
4
+ "description": "Piqo - file watcher extension for pi that processes @piqo markers",
5
+ "keywords": ["pi-package"],
6
+ "pi": {
7
+ "extensions": ["./index.ts"]
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ # Test File
2
+
3
+ Here's some existing content.
4
+
5
+ @piqo add a Python function that calculates the fibonacci sequence recursively
6
+
7
+ ## More content below
8
+
9
+ This part should stay intact.