@henryqw/pi-notes 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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/extensions/notes.ts +304 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Wang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# `@henryqw/pi-notes`
|
|
2
|
+
|
|
3
|
+
Persistent notes shown in a Pi widget, managed with slash commands.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@henryqw/pi-notes
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
| Surface | Type | Purpose |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| `/note <text>` | command | Add a note for current Git worktree (max 4). |
|
|
16
|
+
| `/note-rm` | command | Pick a note from current worktree to remove. |
|
|
17
|
+
| `/note-clear` | command | Clear current worktree's notes. |
|
|
18
|
+
| `/note-prune` | command | Delete notes for repositories and worktrees that no longer exist. |
|
|
19
|
+
|
|
20
|
+
Notes are isolated per Git worktree, render as a numbered widget above editor, and persist across sessions under `~/.pi/agent/config/pi-notes/`. Startup only reads config. `/note-prune` is explicit stale-data cleanup.
|
|
21
|
+
|
|
22
|
+
Each worktree file is validated as untrusted data. Malformed files are preserved and block mutation for affected worktree until fixed or reset with `/note-clear`; `/note-prune` reports but never deletes malformed files.
|
|
23
|
+
|
|
24
|
+
## Remove
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi remove npm:@henryqw/pi-notes
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Development
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm test --workspace @henryqw/pi-notes
|
|
34
|
+
npm run typecheck --workspace @henryqw/pi-notes
|
|
35
|
+
npm run pack:check --workspace @henryqw/pi-notes
|
|
36
|
+
```
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import {
|
|
6
|
+
getAgentDir,
|
|
7
|
+
type ExtensionAPI,
|
|
8
|
+
type ExtensionContext,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
export const MAX_NOTES = 4;
|
|
12
|
+
const WIDGET_KEY = "pi-notes";
|
|
13
|
+
|
|
14
|
+
interface WorktreeIdentity {
|
|
15
|
+
repository: string;
|
|
16
|
+
worktree: string;
|
|
17
|
+
gitDir: string;
|
|
18
|
+
generation: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface NotesRecord extends WorktreeIdentity {
|
|
22
|
+
notes: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface LoadedNotes {
|
|
26
|
+
notes: string[];
|
|
27
|
+
issue?: "malformed" | "stale";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const configDir = () => join(getAgentDir(), "config", "pi-notes");
|
|
31
|
+
const notesPath = (worktree: string) => join(configDir(), `${createHash("sha256").update(worktree).digest("hex")}.json`);
|
|
32
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
33
|
+
|
|
34
|
+
const isSafeNote = (note: unknown): note is string =>
|
|
35
|
+
typeof note === "string" && note.trim().length > 0 && !CONTROL_CHARACTERS.test(note);
|
|
36
|
+
|
|
37
|
+
const recordIdentity = ({ repository, worktree, gitDir, generation }: NotesRecord): WorktreeIdentity =>
|
|
38
|
+
({ repository, worktree, gitDir, generation });
|
|
39
|
+
|
|
40
|
+
const isMissing = (error: unknown) => ["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "");
|
|
41
|
+
|
|
42
|
+
async function worktreeGeneration(gitDir: string): Promise<string> {
|
|
43
|
+
const metadata = await stat(gitDir, { bigint: true });
|
|
44
|
+
return `${metadata.dev}:${metadata.ino}:${metadata.birthtimeNs}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Notes files are untrusted user data. Invalid records throw so callers preserve them. */
|
|
48
|
+
export function parseNotes(raw: string): NotesRecord {
|
|
49
|
+
const data: unknown = JSON.parse(raw);
|
|
50
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError("notes config must be an object");
|
|
51
|
+
const input = data as Record<string, unknown>;
|
|
52
|
+
if (Object.keys(input).sort().join(",") !== "generation,gitDir,notes,repository,worktree"
|
|
53
|
+
|| typeof input.repository !== "string" || !isAbsolute(input.repository)
|
|
54
|
+
|| typeof input.worktree !== "string" || !isAbsolute(input.worktree)
|
|
55
|
+
|| typeof input.gitDir !== "string" || !isAbsolute(input.gitDir)
|
|
56
|
+
|| typeof input.generation !== "string" || !/^\d+:\d+:\d+$/.test(input.generation)
|
|
57
|
+
|| !Array.isArray(input.notes)
|
|
58
|
+
|| input.notes.length > MAX_NOTES
|
|
59
|
+
|| !input.notes.every(isSafeNote)) {
|
|
60
|
+
throw new TypeError(`notes config must identify one worktree and contain at most ${MAX_NOTES} safe non-empty strings`);
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
repository: input.repository,
|
|
64
|
+
worktree: input.worktree,
|
|
65
|
+
gitDir: input.gitDir,
|
|
66
|
+
generation: input.generation,
|
|
67
|
+
notes: input.notes.map((note) => note.replace(/\s+/g, " ").trim()),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderNotes(notes: string[]): string[] {
|
|
72
|
+
return notes.length ? notes.map((note, i) => `${i + 1}. ${note}`) : ["no notes"];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function resolveWorktree(pi: ExtensionAPI, cwd: string): Promise<WorktreeIdentity> {
|
|
76
|
+
const result = await pi.exec(
|
|
77
|
+
"git",
|
|
78
|
+
["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir", "--git-dir"],
|
|
79
|
+
{ cwd },
|
|
80
|
+
);
|
|
81
|
+
if (result.code !== 0 || result.killed) throw new Error("pi-notes requires a Git worktree");
|
|
82
|
+
const [worktree, repository, gitDir, ...extra] = result.stdout.trim().split(/\r?\n/);
|
|
83
|
+
if (!worktree || !repository || !gitDir || extra.length) throw new Error("git returned an invalid worktree identity");
|
|
84
|
+
const [canonicalWorktree, canonicalRepository, canonicalGitDir] = await Promise.all([
|
|
85
|
+
realpath(worktree),
|
|
86
|
+
realpath(repository),
|
|
87
|
+
realpath(gitDir),
|
|
88
|
+
]);
|
|
89
|
+
return {
|
|
90
|
+
worktree: canonicalWorktree,
|
|
91
|
+
repository: canonicalRepository,
|
|
92
|
+
gitDir: canonicalGitDir,
|
|
93
|
+
generation: await worktreeGeneration(canonicalGitDir),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function loadNotes(identity: WorktreeIdentity): Promise<LoadedNotes> {
|
|
98
|
+
let raw: string;
|
|
99
|
+
try {
|
|
100
|
+
raw = await readFile(notesPath(identity.worktree), "utf8");
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { notes: [] };
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
let record: NotesRecord;
|
|
106
|
+
try {
|
|
107
|
+
record = parseNotes(raw);
|
|
108
|
+
} catch {
|
|
109
|
+
return { notes: [], issue: "malformed" };
|
|
110
|
+
}
|
|
111
|
+
return isDeepStrictEqual(recordIdentity(record), identity)
|
|
112
|
+
? { notes: record.notes }
|
|
113
|
+
: { notes: [], issue: "stale" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function persist(identity: WorktreeIdentity, notes: string[]): Promise<void> {
|
|
117
|
+
const path = notesPath(identity.worktree);
|
|
118
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
119
|
+
await mkdir(configDir(), { recursive: true });
|
|
120
|
+
try {
|
|
121
|
+
await writeFile(temp, `${JSON.stringify({ ...identity, notes }, null, "\t")}\n`, { mode: 0o600 });
|
|
122
|
+
await rename(temp, path);
|
|
123
|
+
} finally {
|
|
124
|
+
await rm(temp, { force: true }).catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function issueMessage(issue: LoadedNotes["issue"]): string | undefined {
|
|
129
|
+
if (issue === "malformed") return "Worktree notes file is malformed; fix it or run /note-clear to reset.";
|
|
130
|
+
if (issue === "stale") return "Worktree notes belong to an old worktree; run /note-prune or /note-clear.";
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readCurrent(pi: ExtensionAPI, ctx: ExtensionContext): Promise<{ identity: WorktreeIdentity; notes: string[] } | undefined> {
|
|
135
|
+
try {
|
|
136
|
+
const identity = await resolveWorktree(pi, ctx.cwd);
|
|
137
|
+
const loaded = await loadNotes(identity);
|
|
138
|
+
const message = issueMessage(loaded.issue);
|
|
139
|
+
if (message) {
|
|
140
|
+
ctx.ui.notify(message, "error");
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
return { identity, notes: loaded.notes };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function refresh(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
151
|
+
try {
|
|
152
|
+
const identity = await resolveWorktree(pi, ctx.cwd);
|
|
153
|
+
const loaded = await loadNotes(identity);
|
|
154
|
+
ctx.ui.setWidget(WIDGET_KEY, issueMessage(loaded.issue) ? [issueMessage(loaded.issue)!] : renderNotes(loaded.notes));
|
|
155
|
+
} catch {
|
|
156
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function pruneStale(pi: ExtensionAPI): Promise<{ removed: number; skipped: number }> {
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = await readdir(configDir(), { withFileTypes: true });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { removed: 0, skipped: 0 };
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
let removed = 0;
|
|
169
|
+
let skipped = 0;
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
172
|
+
const path = join(configDir(), entry.name);
|
|
173
|
+
let record: NotesRecord;
|
|
174
|
+
try {
|
|
175
|
+
record = parseNotes(await readFile(path, "utf8"));
|
|
176
|
+
} catch {
|
|
177
|
+
skipped++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (path !== notesPath(record.worktree)) {
|
|
181
|
+
skipped++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
let stale = false;
|
|
185
|
+
try {
|
|
186
|
+
const [worktree, repository, gitDir] = await Promise.all([
|
|
187
|
+
realpath(record.worktree),
|
|
188
|
+
realpath(record.repository),
|
|
189
|
+
realpath(record.gitDir),
|
|
190
|
+
]);
|
|
191
|
+
stale = worktree !== record.worktree
|
|
192
|
+
|| repository !== record.repository
|
|
193
|
+
|| gitDir !== record.gitDir
|
|
194
|
+
|| await worktreeGeneration(gitDir) !== record.generation;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (isMissing(error)) stale = true;
|
|
197
|
+
else {
|
|
198
|
+
skipped++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!stale) {
|
|
203
|
+
let current: WorktreeIdentity;
|
|
204
|
+
try {
|
|
205
|
+
current = await resolveWorktree(pi, record.worktree);
|
|
206
|
+
} catch {
|
|
207
|
+
skipped++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
stale = !isDeepStrictEqual(current, recordIdentity(record));
|
|
211
|
+
}
|
|
212
|
+
if (stale) {
|
|
213
|
+
await rm(path, { force: true });
|
|
214
|
+
removed++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { removed, skipped };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default function notesExtension(pi: ExtensionAPI): void {
|
|
221
|
+
pi.on("session_start", (_event, ctx) => refresh(pi, ctx));
|
|
222
|
+
|
|
223
|
+
pi.registerCommand("note", {
|
|
224
|
+
description: `Add a note to this Git worktree (max ${MAX_NOTES})`,
|
|
225
|
+
handler: async (args, ctx) => {
|
|
226
|
+
const text = args.replace(/\s+/g, " ").trim();
|
|
227
|
+
if (!text) {
|
|
228
|
+
ctx.ui.notify("Usage: /note <text>", "warning");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!isSafeNote(text)) {
|
|
232
|
+
ctx.ui.notify("Notes cannot contain terminal control characters.", "warning");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const current = await readCurrent(pi, ctx);
|
|
236
|
+
if (!current) return;
|
|
237
|
+
if (current.notes.length >= MAX_NOTES) {
|
|
238
|
+
ctx.ui.notify(`Widget full (${MAX_NOTES} notes). Remove one with /note-rm.`, "warning");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
current.notes.push(text);
|
|
242
|
+
await persist(current.identity, current.notes);
|
|
243
|
+
ctx.ui.setWidget(WIDGET_KEY, renderNotes(current.notes));
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
pi.registerCommand("note-rm", {
|
|
248
|
+
description: "Remove a note from this Git worktree",
|
|
249
|
+
handler: async (args, ctx) => {
|
|
250
|
+
if (args.trim()) {
|
|
251
|
+
ctx.ui.notify("Usage: /note-rm", "warning");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const snapshot = await readCurrent(pi, ctx);
|
|
255
|
+
if (!snapshot) return;
|
|
256
|
+
if (!snapshot.notes.length) {
|
|
257
|
+
ctx.ui.notify("No notes to remove.", "info");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const choice = await ctx.ui.select("Remove note:", renderNotes(snapshot.notes));
|
|
261
|
+
if (!choice) return;
|
|
262
|
+
const current = await readCurrent(pi, ctx);
|
|
263
|
+
if (!current) return;
|
|
264
|
+
const index = Number.parseInt(/^(\d+)\./.exec(choice)?.[1] ?? "", 10) - 1;
|
|
265
|
+
if (!isDeepStrictEqual(current, snapshot) || !Number.isInteger(index) || index < 0 || index >= current.notes.length) {
|
|
266
|
+
ctx.ui.notify("Notes changed elsewhere; try /note-rm again.", "warning");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
current.notes.splice(index, 1);
|
|
270
|
+
await persist(current.identity, current.notes);
|
|
271
|
+
ctx.ui.setWidget(WIDGET_KEY, renderNotes(current.notes));
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
pi.registerCommand("note-clear", {
|
|
276
|
+
description: "Clear notes for this Git worktree",
|
|
277
|
+
handler: async (args, ctx) => {
|
|
278
|
+
if (args.trim()) {
|
|
279
|
+
ctx.ui.notify("Usage: /note-clear", "warning");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const identity = await resolveWorktree(pi, ctx.cwd);
|
|
284
|
+
await rm(notesPath(identity.worktree), { force: true });
|
|
285
|
+
ctx.ui.setWidget(WIDGET_KEY, renderNotes([]));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
pi.registerCommand("note-prune", {
|
|
293
|
+
description: "Delete notes for removed repositories and worktrees",
|
|
294
|
+
handler: async (args, ctx) => {
|
|
295
|
+
if (args.trim()) {
|
|
296
|
+
ctx.ui.notify("Usage: /note-prune", "warning");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const { removed, skipped } = await pruneStale(pi);
|
|
300
|
+
ctx.ui.notify(`Removed ${removed} stale worktree note file${removed === 1 ? "" : "s"}; preserved ${skipped} unchecked or invalid file${skipped === 1 ? "" : "s"}.`, "info");
|
|
301
|
+
await refresh(pi, ctx);
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-notes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Persistent notes in a Pi widget, managed with /note commands.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"notes",
|
|
9
|
+
"widget"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22.19.0"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.ts",
|
|
23
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
|
|
24
|
+
"pack:check": "npm pack --dry-run"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": "^0.84.2"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
32
|
+
"directory": "packages/pi-notes"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./extensions/notes.ts"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|