@vs4vijay/piverse 0.1.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@vs4vijay/pi-notes",
3
+ "version": "0.6.0",
4
+ "description": "Project-level notes for the Pi coding agent — /notes command with CLI and TUI",
5
+ "type": "module",
6
+ "keywords": ["pi-package"],
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/vs4vijay/piverse.git"
11
+ },
12
+ "author": "vs4vijay",
13
+ "homepage": "https://github.com/vs4vijay/piverse",
14
+ "bugs": {
15
+ "url": "https://github.com/vs4vijay/piverse/issues"
16
+ },
17
+ "pi": {
18
+ "extensions": ["./src/index.ts"]
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*",
25
+ "@earendil-works/pi-tui": "*",
26
+ "typebox": "*"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^5.0.0"
30
+ }
31
+ }
@@ -0,0 +1,151 @@
1
+ # pi-notes — Feature Plan
2
+
3
+ ## Current State
4
+
5
+ Project-level note-taking extension for Pi coding agent. Persists notes as JSON in `.pi/notes/notes.json`. Primary interface is a TUI opened via `/notes`, with CLI fallbacks for common operations.
6
+
7
+ **Implemented:** MVP (store, core commands, TUI), CLI + TUI search, tags, pinning, external-editor title/content editing (`edit`), and JSON export/import. Stores are cached per-project so multi-project sessions don't collide.
8
+
9
+ ---
10
+
11
+ ## Planned Features
12
+
13
+ ### Phase 1: Foundation (MVP)
14
+
15
+ #### 1.1 Note Store & Persistence
16
+ - Load notes from `.pi/notes/notes.json` on `session_start`
17
+ - Save notes after every mutation (create/update/delete)
18
+ - Also save on `session_shutdown` (safety net)
19
+ - Create directory `.pi/notes/` if missing
20
+ - Note schema: `{ id: string, title: string, content: string, createdAt: ISO, updatedAt: ISO }`
21
+ - ID generation: `crypto.randomUUID()`
22
+
23
+ #### 1.2 Core Commands
24
+ | Command | Behavior |
25
+ |---------|----------|
26
+ | `/notes` | Open TUI (list/view/edit/create) |
27
+ | `/notes <title>` | Quick-add note with title (opens editor for content) |
28
+ | `/notes list` | CLI: list all notes with index, title, preview |
29
+ | `/notes show <id>` | CLI: print full note content (markdown) |
30
+ | `/notes rm <id>` | CLI: delete note by ID (with confirmation) |
31
+
32
+ #### 1.3 TUI — List View
33
+ - Full-screen overlay via `ctx.ui.custom()`
34
+ - Left pane: `SelectList` of notes (title + first 60 chars of content)
35
+ - Right pane: `Markdown` preview of selected note
36
+ - Keys: `↑/↓` navigate, `Enter` view full, `n` new, `e` edit, `d` delete, `q` quit
37
+ - Header: "Pi Notes [n] new [q] quit"
38
+ - Fuzzy filter on type (like session selector)
39
+
40
+ #### 1.4 TUI — View Mode
41
+ - Full-screen markdown render of selected note
42
+ - Keys: `q`/`Esc` back to list, `e` edit
43
+
44
+ #### 1.5 TUI — Create/Edit Mode
45
+ - `ctx.ui.editor(title, prefill)` for title + content
46
+ - On save: persist, return to list with new/updated note selected
47
+ - On cancel: return to list unchanged
48
+
49
+ ---
50
+
51
+ ### Phase 2: Quality of Life
52
+
53
+ #### 2.1 Search & Filter
54
+ - `/notes search <query>` — CLI: filter notes by title/content match
55
+ - TUI: live filter as you type (`/` to start, type to filter, `Esc`/`Enter` to finish)
56
+ - Show match count in header
57
+
58
+ #### 2.2 Tags (Frontmatter Extension)
59
+ - Optional tags in note frontmatter: `tags: ["architecture", "decision"]`
60
+ - `/notes tag <id> <tag>` — add tag
61
+ - `/notes untag <id> <tag>` — remove tag
62
+ - TUI: filter by tag (`t` key cycles tag filters)
63
+ - CLI: `/notes list --tag <tag>`
64
+
65
+ #### 2.3 Pinning
66
+ - `/notes pin <id>` / `/notes unpin <id>` — pin to top of list
67
+ - TUI: `p` toggles pin, pinned notes show 📌 prefix
68
+ - Pinned notes sort above unpinned (both by updatedAt desc)
69
+
70
+ ---
71
+
72
+ ### Phase 3: Power Features
73
+
74
+ #### 3.1 Linking & References
75
+ - `[[note-id]]` or `[[note-title]]` syntax in content
76
+ - TUI: `Enter` on link navigates to linked note
77
+ - CLI: `/notes links <id>` — show backlinks
78
+
79
+ #### 3.2 Templates
80
+ - `/notes template <name>` — create note from template
81
+ - Built-in templates: `decision`, `meeting`, `todo`, `snippet`
82
+ - Templates stored in `.pi/notes/templates/*.md`
83
+
84
+ ---
85
+
86
+ ### Completed
87
+
88
+ #### Export/Import (CLI)
89
+ - `/notes export [path]` — export all notes as JSON (default `./notes.json` in cwd)
90
+ - `/notes import <path>` — import and merge from JSON; incoming notes whose `id` already exists are skipped (existing notes win)
91
+ - Reuses the atomic (temp + rename) write; validates the imported file shape
92
+
93
+ #### Title/Content Editing (CLI)
94
+ - `/notes edit <id>` — edit a note's title/content via external editor, prefilled with current values
95
+
96
+ #### Per-Project Store Caching
97
+ - `getNoteStore` caches one `NoteStore` per `cwd` (was a single global instance), so notes for different projects in one session don't collide
98
+ - `getNoteStore()` (no arg) defaults to `process.cwd()`
99
+
100
+ ---
101
+
102
+ ## Implementation Priority
103
+
104
+ | Phase | Features | Why |
105
+ |-------|----------|-----|
106
+ | 1 | Store, Core Commands, TUI (List/View/Edit) | Working note system end-to-end |
107
+ | 2 | Search, Tags, Pinning | Daily workflow improvements |
108
+ | 3 | Linking, Templates, Export | Advanced organization |
109
+
110
+ ---
111
+
112
+ ## Technical Notes
113
+
114
+ - All commands register via `pi.registerCommand("notes", ...)`
115
+ - Subcommands parsed from `args` string in handler
116
+ - TUI uses pi-tui: `SelectList`, `Markdown`, `Editor`, `VStack`, `HStack`, `Box`, `Text`
117
+ - Persistence: JSON file, atomic write (write temp → rename)
118
+ - Error handling: notify user via `ctx.ui.notify()`, never throw
119
+ - Follows Pi Queue patterns for `session_start`/`session_shutdown`
120
+
121
+ ---
122
+
123
+ ## File Structure
124
+
125
+ ```
126
+ extensions/pi-notes/
127
+ ├── src/
128
+ │ ├── index.ts # Extension entry, command registration, hooks
129
+ │ ├── store.ts # NoteStore class: load/save/CRUD
130
+ │ ├── tui.ts # TUI components: ListView, ViewMode, EditorMode
131
+ │ └── types.ts # Note interface, CommandArgs types
132
+ ├── package.json
133
+ ├── README.md
134
+ └── plan.md # This file
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Acceptance Criteria (Phase 1)
140
+
141
+ - [ ] `/notes` opens TUI with empty list (first run)
142
+ - [ ] `/notes "My Title"` creates note, opens editor for content
143
+ - [ ] TUI: list shows notes, preview updates on selection
144
+ - [ ] TUI: `n` → editor → save → note appears in list
145
+ - [ ] TUI: `e` on selected → editor → save → updates note
146
+ - [ ] TUI: `d` on selected → confirm → removes note
147
+ - [ ] `/notes list` prints formatted table in CLI
148
+ - [ ] `/notes show <id>` prints full markdown
149
+ - [ ] `/notes rm <id>` confirms then deletes
150
+ - [ ] Notes persist across `/reload` and session restart
151
+ - [ ] TypeScript compiles without errors
@@ -0,0 +1,369 @@
1
+ /**
2
+ * pi-notes — Project-level notes for Pi coding agent
3
+ */
4
+
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { getNoteStore, resetNoteStore } from "./store.js";
7
+ import type { Note } from "./types.js";
8
+ import { parseNotesCommand, type ParsedCommand } from "./types.js";
9
+ import { createNotesTUI } from "./tui.js";
10
+ import { isAbsolute, join } from "path";
11
+
12
+ let tuiHandle: (() => void) | null = null;
13
+
14
+ export default async function (pi: ExtensionAPI) {
15
+ // Initialize store on session start
16
+ pi.on("session_start", async (_event, ctx) => {
17
+ resetNoteStore(); // Ensure fresh instance per session
18
+ const store = getNoteStore(ctx.cwd);
19
+ await store.load();
20
+ });
21
+
22
+ // Save on shutdown
23
+ pi.on("session_shutdown", async () => {
24
+ // Store auto-saves on mutations, but ensure final flush
25
+ const store = getNoteStore();
26
+ const notes = await store.getAll();
27
+ await store.save(notes);
28
+ });
29
+
30
+ // Main /notes command
31
+ pi.registerCommand("notes", {
32
+ description: "Project notes — TUI (no args) or quick-add: /notes <title>",
33
+ handler: async (args, ctx) => {
34
+ const parsed = parseNotesCommand(args);
35
+ const store = getNoteStore(ctx.cwd);
36
+ const notes = await store.getAll();
37
+
38
+ // Handle subcommands
39
+ switch (parsed.subcommand) {
40
+ case "list":
41
+ await handleList(notes, ctx);
42
+ return;
43
+ case "show":
44
+ await handleShow(parsed.args, notes, ctx);
45
+ return;
46
+ case "rm":
47
+ await handleRemove(parsed.args, store, ctx);
48
+ return;
49
+ case "edit":
50
+ await handleEdit(parsed.args, store, ctx);
51
+ return;
52
+ case "search":
53
+ await handleSearch(parsed.args, notes, ctx);
54
+ return;
55
+ case "tag":
56
+ await handleTag(parsed.args, store, ctx);
57
+ return;
58
+ case "untag":
59
+ await handleUntag(parsed.args, store, ctx);
60
+ return;
61
+ case "pin":
62
+ await handlePin(parsed.args, store, ctx);
63
+ return;
64
+ case "unpin":
65
+ await handleUnpin(parsed.args, store, ctx);
66
+ return;
67
+ case "export":
68
+ await handleExport(parsed.args, store, ctx);
69
+ return;
70
+ case "import":
71
+ await handleImport(parsed.args, store, ctx);
72
+ return;
73
+ default:
74
+ // Quick-add or open TUI
75
+ if (parsed.args.length > 0) {
76
+ await handleQuickAdd(parsed.args.join(" "), store, ctx);
77
+ } else {
78
+ await openTUI(ctx, notes);
79
+ }
80
+ }
81
+ },
82
+ });
83
+ }
84
+
85
+ async function handleList(notes: Note[], ctx: ExtensionCommandContext): Promise<void> {
86
+ if (notes.length === 0) {
87
+ ctx.ui.notify("No notes yet. Use /notes <title> to create one.", "info");
88
+ return;
89
+ }
90
+
91
+ const lines = notes.map((n, i) => {
92
+ const pin = n.pinned ? "📌 " : "";
93
+ const tags = n.tags?.length ? ` [${n.tags.join(", ")}]` : "";
94
+ const preview = n.content.slice(0, 50).replace(/\n/g, " ");
95
+ return `${i + 1}. ${pin}${n.title}${tags} — ${preview}`;
96
+ });
97
+
98
+ ctx.ui.notify(`Notes (${notes.length}):\n${lines.join("\n")}`, "info");
99
+ }
100
+
101
+ async function handleShow(args: string[], notes: Note[], ctx: ExtensionCommandContext): Promise<void> {
102
+ if (args.length === 0) {
103
+ ctx.ui.notify("Usage: /notes show <id>", "error");
104
+ return;
105
+ }
106
+
107
+ const id = args[0];
108
+ const note = notes.find((n) => n.id === id || n.id.startsWith(id));
109
+
110
+ if (!note) {
111
+ ctx.ui.notify(`Note not found: ${id}`, "error");
112
+ return;
113
+ }
114
+
115
+ const meta = `📌 ${note.pinned ? "pinned" : ""} | Tags: ${note.tags?.join(", ") || "none"} | Created: ${note.createdAt} | Updated: ${note.updatedAt}`;
116
+ ctx.ui.notify(`# ${note.title}\n${meta}\n\n${note.content || "*(empty)*"}`, "info");
117
+ }
118
+
119
+ async function handleRemove(args: string[], store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
120
+ if (args.length === 0) {
121
+ ctx.ui.notify("Usage: /notes rm <id>", "error");
122
+ return;
123
+ }
124
+
125
+ const id = args[0];
126
+ const notes = await store.getAll();
127
+ const note = notes.find((n) => n.id === id || n.id.startsWith(id));
128
+
129
+ if (!note) {
130
+ ctx.ui.notify(`Note not found: ${id}`, "error");
131
+ return;
132
+ }
133
+
134
+ // Confirm via TUI confirm dialog
135
+ const confirmed = await ctx.ui.confirm(
136
+ "Delete note",
137
+ `Delete "${note.title}"? This cannot be undone.`
138
+ );
139
+
140
+ if (confirmed) {
141
+ const deleted = await store.delete(id);
142
+ if (deleted) {
143
+ ctx.ui.notify(`Deleted note: ${note.title}`, "info");
144
+ } else {
145
+ ctx.ui.notify("Delete failed", "error");
146
+ }
147
+ }
148
+ }
149
+
150
+ async function handleEdit(args: string[], store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
151
+ if (args.length === 0) {
152
+ ctx.ui.notify("Usage: /notes edit <id>", "error");
153
+ return;
154
+ }
155
+
156
+ const id = args[0];
157
+ const notes = await store.getAll();
158
+ const note = notes.find((n) => n.id === id || n.id.startsWith(id));
159
+
160
+ if (!note) {
161
+ ctx.ui.notify(`Note not found: ${id}`, "error");
162
+ return;
163
+ }
164
+
165
+ const title = await ctx.ui.editor(`Edit: ${note.title}`, note.title);
166
+ if (title === undefined) {
167
+ ctx.ui.notify("Cancelled", "info");
168
+ return;
169
+ }
170
+
171
+ const content = await ctx.ui.editor(`Content for: ${title}`, note.content);
172
+ if (content === undefined) {
173
+ ctx.ui.notify("Cancelled", "info");
174
+ return;
175
+ }
176
+
177
+ await store.update(note.id, { title, content });
178
+ ctx.ui.notify(`Updated note: ${title}`, "info");
179
+ }
180
+
181
+ async function handleSearch(args: string[], notes: Note[], ctx: ExtensionCommandContext): Promise<void> {
182
+ if (args.length === 0) {
183
+ ctx.ui.notify("Usage: /notes search <query>", "error");
184
+ return;
185
+ }
186
+
187
+ const query = args.join(" ").toLowerCase();
188
+ const results = notes.filter(
189
+ (n) => n.title.toLowerCase().includes(query) || n.content.toLowerCase().includes(query)
190
+ );
191
+
192
+ if (results.length === 0) {
193
+ ctx.ui.notify(`No notes matching: ${query}`, "info");
194
+ return;
195
+ }
196
+
197
+ const lines = results.map((n, i) => {
198
+ const pin = n.pinned ? "📌 " : "";
199
+ const preview = n.content.slice(0, 50).replace(/\n/g, " ");
200
+ return `${i + 1}. ${pin}${n.title} — ${preview}`;
201
+ });
202
+
203
+ ctx.ui.notify(`Search results (${results.length}):\n${lines.join("\n")}`, "info");
204
+ }
205
+
206
+ async function handleTag(args: string[], store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
207
+ if (args.length < 2) {
208
+ ctx.ui.notify("Usage: /notes tag <id> <tag>", "error");
209
+ return;
210
+ }
211
+
212
+ const [id, tag] = args;
213
+ const note = await store.getById(id);
214
+
215
+ if (!note) {
216
+ ctx.ui.notify(`Note not found: ${id}`, "error");
217
+ return;
218
+ }
219
+
220
+ const tags = [...new Set([...(note.tags || []), tag])];
221
+ await store.update(id, { tags });
222
+ ctx.ui.notify(`Added tag "${tag}" to "${note.title}"`, "info");
223
+ }
224
+
225
+ async function handleUntag(args: string[], store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
226
+ if (args.length < 2) {
227
+ ctx.ui.notify("Usage: /notes untag <id> <tag>", "error");
228
+ return;
229
+ }
230
+
231
+ const [id, tag] = args;
232
+ const note = await store.getById(id);
233
+
234
+ if (!note) {
235
+ ctx.ui.notify(`Note not found: ${id}`, "error");
236
+ return;
237
+ }
238
+
239
+ const tags = (note.tags || []).filter((t) => t !== tag);
240
+ await store.update(id, { tags });
241
+ ctx.ui.notify(`Removed tag "${tag}" from "${note.title}"`, "info");
242
+ }
243
+
244
+ async function handlePin(args: string[], store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
245
+ if (args.length === 0) {
246
+ ctx.ui.notify("Usage: /notes pin <id>", "error");
247
+ return;
248
+ }
249
+
250
+ const id = args[0];
251
+ const note = await store.getById(id);
252
+
253
+ if (!note) {
254
+ ctx.ui.notify(`Note not found: ${id}`, "error");
255
+ return;
256
+ }
257
+
258
+ await store.update(id, { pinned: true });
259
+ ctx.ui.notify(`Pinned "${note.title}"`, "info");
260
+ }
261
+
262
+ async function handleUnpin(args: string[], store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
263
+ if (args.length === 0) {
264
+ ctx.ui.notify("Usage: /notes unpin <id>", "error");
265
+ return;
266
+ }
267
+
268
+ const id = args[0];
269
+ const note = await store.getById(id);
270
+
271
+ if (!note) {
272
+ ctx.ui.notify(`Note not found: ${id}`, "error");
273
+ return;
274
+ }
275
+
276
+ await store.update(id, { pinned: false });
277
+ ctx.ui.notify(`Unpinned "${note.title}"`, "info");
278
+ }
279
+
280
+ async function handleQuickAdd(title: string, store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
281
+ // Strip surrounding quotes from a quoted title
282
+ title = title.replace(/^(['"])(.*)\1$/, "$2");
283
+
284
+ // Open external editor for content
285
+ const content = await ctx.ui.editor(`New note: ${title}`, "");
286
+
287
+ if (content === undefined) {
288
+ ctx.ui.notify("Cancelled", "info");
289
+ return;
290
+ }
291
+
292
+ const note = await store.create(title, content);
293
+ ctx.ui.notify(`Created note: ${note.title}`, "info");
294
+ }
295
+
296
+ async function handleExport(args: string[], store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
297
+ const filePath = resolvePath(ctx.cwd, args[0] ?? "notes.json");
298
+ const count = await store.exportTo(filePath);
299
+ ctx.ui.notify(`Exported ${count} notes to ${filePath}`, "info");
300
+ }
301
+
302
+ async function handleImport(args: string[], store: ReturnType<typeof getNoteStore>, ctx: ExtensionCommandContext): Promise<void> {
303
+ if (args.length === 0) {
304
+ ctx.ui.notify("Usage: /notes import <path>", "error");
305
+ return;
306
+ }
307
+
308
+ const filePath = resolvePath(ctx.cwd, args[0]);
309
+ try {
310
+ const { imported, skipped } = await store.importFrom(filePath);
311
+ const skippedText = skipped > 0 ? ` (${skipped} already present, skipped)` : "";
312
+ ctx.ui.notify(`Imported ${imported} note(s) from ${filePath}${skippedText}`, "info");
313
+ } catch (err) {
314
+ ctx.ui.notify(`Import failed: ${err instanceof Error ? err.message : String(err)}`, "error");
315
+ }
316
+ }
317
+
318
+ function resolvePath(cwd: string, p: string): string {
319
+ return isAbsolute(p) ? p : join(cwd, p);
320
+ }
321
+
322
+ async function openTUI(ctx: ExtensionCommandContext, initialNotes: Note[]): Promise<void> {
323
+ const store = getNoteStore(ctx.cwd);
324
+
325
+ const component = createNotesTUI(
326
+ initialNotes,
327
+ ctx.ui.theme,
328
+ () => {
329
+ // onClose
330
+ tuiHandle?.();
331
+ tuiHandle = null;
332
+ },
333
+ async (note?: Note): Promise<Note[] | undefined> => {
334
+ // External editor: create or edit a note
335
+ if (note) {
336
+ const title = await ctx.ui.editor(`Edit: ${note.title}`, note.title);
337
+ if (title === undefined) return undefined;
338
+ const content = await ctx.ui.editor(`Content for: ${title}`, note.content);
339
+ if (content === undefined) return undefined;
340
+ await store.update(note.id, { title, content });
341
+ } else {
342
+ const title = await ctx.ui.editor("New Note", "");
343
+ if (title === undefined) return undefined;
344
+ const content = await ctx.ui.editor(`Content for: ${title}`, "");
345
+ if (content === undefined) return undefined;
346
+ await store.create(title, content);
347
+ }
348
+ return store.getAll();
349
+ },
350
+ async (id: string): Promise<Note[]> => {
351
+ await store.delete(id);
352
+ return store.getAll();
353
+ },
354
+ async (note: Note): Promise<Note[]> => {
355
+ await store.update(note.id, { pinned: !note.pinned });
356
+ return store.getAll();
357
+ }
358
+ );
359
+
360
+ // Show custom overlay
361
+ await ctx.ui.custom(
362
+ (tui, theme, keybindings, done) => {
363
+ // The component is already a Focusable Component
364
+ tuiHandle = () => done(undefined);
365
+ return component;
366
+ },
367
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } }
368
+ );
369
+ }