pi-note 0.4.0 → 0.5.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/README.md +21 -7
- package/index.ts +90 -26
- package/package.json +1 -1
- package/setup.ts +14 -4
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# pi-note
|
|
2
2
|
|
|
3
3
|
Persistent [pinote](https://github.com/kvidzibo/pinote) tasks and Markdown handoffs
|
|
4
|
-
in Pi: selected-task footer status, `/pi-note`, and agent read/update tools.
|
|
4
|
+
in Pi: selected-task footer status, `/pi-note`, and agent read/update/add/tag tools.
|
|
5
5
|
The extension and Python app share a repository but install separately.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
Requires Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH).
|
|
9
|
+
Requires Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH). Add and tag need **pinote 0.4.0+**.
|
|
10
10
|
From this checkout, install the extension with `pi install ./pi`, then run
|
|
11
11
|
`/reload` in interactive Pi. Use `/pi-note-setup` if the CLI is missing, or the
|
|
12
12
|
suggested `/pi-note-upgrade` if it is older than the bundled CLI. The planned npm package name is
|
|
@@ -17,7 +17,7 @@ It is not published yet. npm installation does not run Python installers.
|
|
|
17
17
|
|
|
18
18
|
Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first and
|
|
19
19
|
restart Pi with `uv` on PATH. At startup the extension checks `note --version`
|
|
20
|
-
against its bundled CLI version (currently 0.
|
|
20
|
+
against its bundled CLI version (currently 0.4.0), without network requests:
|
|
21
21
|
|
|
22
22
|
- Missing/unrecognized CLI: show `/pi-note-setup` in the slash-command menu.
|
|
23
23
|
- Older CLI: show and suggest `/pi-note-upgrade`.
|
|
@@ -55,8 +55,11 @@ standalone `settings/pi/extensions/pinote.ts` entry if installed; load only one
|
|
|
55
55
|
|
|
56
56
|
## Use
|
|
57
57
|
|
|
58
|
-
`/pi-note` picks a task, starts it, and
|
|
59
|
-
|
|
58
|
+
`/pi-note` picks a task, starts it, and adds a short task-ID prompt to the editor:
|
|
59
|
+
“Read task #121 with pinote_get and work on it. Ask only if blocked. Save progress
|
|
60
|
+
with pinote_update.” The agent fetches current task text and saved handoff fields
|
|
61
|
+
when you submit; they are not copied into the draft. Existing drafts are preserved;
|
|
62
|
+
nothing is submitted automatically.
|
|
60
63
|
With a selected task it offers **Continue**, **Done**, and **Switch task**.
|
|
61
64
|
Type in the task picker to filter by text, ID, tag, or state (all words must match).
|
|
62
65
|
Use ↑/↓ and Enter to select, or Esc to cancel. Rows show **●** for in progress or
|
|
@@ -66,7 +69,7 @@ The footer omits the task ID and state and shows only the first line, truncated
|
|
|
66
69
|
The pin glyph is bundled in `icons/note.txt`; it uses the terminal's emoji font, not a Nerd Font or icon theme.
|
|
67
70
|
|
|
68
71
|
Selection is stored per canonical working directory in pinote's database. A new
|
|
69
|
-
Pi session shows that selection; use **Continue** to
|
|
72
|
+
Pi session shows that selection; use **Continue** to insert its task-ID prompt.
|
|
70
73
|
Completing, removing, or scheduling a task clears its selections. Switching does
|
|
71
74
|
not complete or reset the previous task. Task status refreshes at session start,
|
|
72
75
|
before/after agent activity, and after commands/tools.
|
|
@@ -74,9 +77,20 @@ before/after agent activity, and after commands/tools.
|
|
|
74
77
|
Agent tools:
|
|
75
78
|
|
|
76
79
|
- `pinote_get`: read the selected task, or an explicit `id`, including its revision.
|
|
80
|
+
Omitting `id` returns null when this project has no selected task.
|
|
77
81
|
- `pinote_update`: supply `id`, `expected_updated_at` from that read, and arbitrary
|
|
78
82
|
`set` label/value pairs or `remove` labels. Updates merge fields, never replace
|
|
79
|
-
the task text. If another process changed the task, read again before retrying.
|
|
83
|
+
the task text or tag. If another process changed the task, read again before retrying.
|
|
84
|
+
- `pinote_tags`: list saved tag names.
|
|
85
|
+
- `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
|
|
86
|
+
selects it for this project; use that only after the user agrees.
|
|
87
|
+
- `pinote_tag`: set or clear a tag on an active or in-progress task. Read first and
|
|
88
|
+
pass `expected_updated_at`.
|
|
89
|
+
|
|
90
|
+
When the user gives work and no task is selected, the agent proposes one note as
|
|
91
|
+
`[tag] text` and asks before creating it. No continues without a note. Yes calls
|
|
92
|
+
`pinote_add` with `select: true`. An existing selection is not replaced unless the
|
|
93
|
+
user asks to switch. Reuse a saved tag name when it fits.
|
|
80
94
|
|
|
81
95
|
Agents are guided to keep notes to three short bullets total: relevant outcome,
|
|
82
96
|
blocker, and next action. Replace stale notes; omit narration, repeated task text,
|
package/index.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute } from "node:path";
|
|
4
5
|
import { stripVTControlCharacters } from "node:util";
|
|
5
6
|
import { Type } from "typebox";
|
|
6
7
|
import { createPRWatcher } from "./pr-watch.ts";
|
|
7
8
|
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
8
|
-
import { bundledCLIVersion, cliMenu, detectCLI,
|
|
9
|
+
import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
|
|
9
10
|
|
|
10
11
|
const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep PR links in the separate PR field.";
|
|
12
|
+
const createGuidance = [
|
|
13
|
+
"When the user gives work and no pinote task is selected, propose one note as `[tag] text` and ask before creating it. On no, continue without a note. On yes, call pinote_add with select true so it becomes this session's active task. Never add or select without a yes. If a task is already selected, do not replace it unless the user asks to switch.",
|
|
14
|
+
"Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. pinote_tag changes an existing active or in-progress task after pinote_get; pass updated_at. Do not use pinote_update for tags or task text.",
|
|
15
|
+
];
|
|
11
16
|
type Task = {
|
|
12
17
|
id: number;
|
|
13
18
|
text: string;
|
|
@@ -58,7 +63,8 @@ function taskList(raw: string): Summary[] {
|
|
|
58
63
|
return value.sort((a, b) => b.id - a.id);
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
function toolResult(value: Task) {
|
|
66
|
+
function toolResult(value: Task | null) {
|
|
67
|
+
if (!value) return { content: [{ type: "text" as const, text: "null" }], details: null };
|
|
62
68
|
// Keep the model payload under 50 KiB at the CLI's limits; don't send the same fields twice.
|
|
63
69
|
const { markdown: _markdown, ...data } = value;
|
|
64
70
|
return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: value };
|
|
@@ -97,17 +103,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
103
|
}
|
|
98
104
|
return result.stdout;
|
|
99
105
|
};
|
|
100
|
-
const run = async (argv: string[], signal?: AbortSignal, canRun = () => true) => {
|
|
106
|
+
const run = async (argv: string[], signal?: AbortSignal, canRun = () => true, minimum = "0.3.0") => {
|
|
101
107
|
if (setupAbort) throw new Error("Pinote CLI setup is still running. Retry after it finishes.");
|
|
102
108
|
// Old pinote treats unknown commands as note text. Probe safely before every call,
|
|
103
109
|
// including tools in headless sessions, and do not cache across CLI upgrades/downgrades.
|
|
104
110
|
const version = (await invoke(["--version"], signal)).trim();
|
|
105
|
-
if (!
|
|
106
|
-
throw new Error(`pi-note requires pinote
|
|
111
|
+
if (!versionAtLeast(version, minimum)) {
|
|
112
|
+
throw new Error(`pi-note requires pinote ${minimum}+; found ${clean(version) || "an unknown CLI"}. ${setupHint}`);
|
|
107
113
|
}
|
|
108
114
|
if (!canRun()) throw new Error("Pinote operation cancelled: the session changed.");
|
|
109
115
|
return invoke(argv, signal);
|
|
110
116
|
};
|
|
117
|
+
const writeTask = async (
|
|
118
|
+
ctx: ExtensionContext, signal: AbortSignal | undefined, argv: string[], id?: number, minimum = "0.3.0",
|
|
119
|
+
) => {
|
|
120
|
+
if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
|
|
121
|
+
const operation = Symbol();
|
|
122
|
+
pending = operation;
|
|
123
|
+
const generation = epoch;
|
|
124
|
+
refreshSerial++;
|
|
125
|
+
try {
|
|
126
|
+
return requiredTask(await run(argv, signal, () => alive && generation === epoch, minimum), id);
|
|
127
|
+
} finally {
|
|
128
|
+
if (pending === operation) pending = undefined;
|
|
129
|
+
if (alive && generation === epoch) await refresh(ctx);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
111
132
|
const selected = async (ctx: ExtensionContext, signal?: AbortSignal) =>
|
|
112
133
|
task(await run(["agent", "selected", "--cwd", ctx.cwd], signal));
|
|
113
134
|
|
|
@@ -261,9 +282,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
261
282
|
const chosen = requiredTask(await run(["agent", "select", String(id), "--cwd", ctx.cwd], undefined, canAct), id);
|
|
262
283
|
if (!canAct()) return;
|
|
263
284
|
const draft = ctx.ui.getEditorText();
|
|
264
|
-
const handoff = `
|
|
265
|
-
"Use pinote_get to read current task data and pinote_update to save agent handoff fields.\n" +
|
|
266
|
-
handoffGuidance + "\n\n" + chosen.markdown;
|
|
285
|
+
const handoff = `Read task #${chosen.id} with pinote_get and work on it. Ask only if blocked. Save progress with pinote_update.`;
|
|
267
286
|
ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
|
|
268
287
|
ctx.ui.notify(`Pinote #${chosen.id} is in progress. Task added to input.`, "info");
|
|
269
288
|
} catch (error) {
|
|
@@ -278,22 +297,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
278
297
|
pi.registerTool({
|
|
279
298
|
name: "pinote_get",
|
|
280
299
|
label: "Pinote get",
|
|
281
|
-
|
|
300
|
+
promptGuidelines: createGuidance,
|
|
301
|
+
description: "Read a pinote task and its Markdown agent fields/revision. Omit id to read this project's selected task; returns null when none is selected. Supply id to read another task. Use the returned updated_at for pinote_update and pinote_tag.",
|
|
282
302
|
parameters: Type.Object({ id: Type.Optional(idSchema) }),
|
|
283
303
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
284
304
|
if (params.id !== undefined && !validId(params.id)) throw new Error("id must be a positive safe integer.");
|
|
285
305
|
const argv = params.id === undefined
|
|
286
306
|
? ["agent", "selected", "--cwd", ctx.cwd]
|
|
287
307
|
: ["agent", "get", String(params.id)];
|
|
288
|
-
const
|
|
289
|
-
return toolResult(
|
|
308
|
+
const raw = await run(argv, signal);
|
|
309
|
+
return toolResult(params.id === undefined ? task(raw) : requiredTask(raw, params.id));
|
|
290
310
|
},
|
|
291
311
|
});
|
|
292
312
|
pi.registerTool({
|
|
293
313
|
name: "pinote_update",
|
|
294
314
|
label: "Pinote update",
|
|
295
315
|
promptGuidelines: [handoffGuidance],
|
|
296
|
-
description: "Patch arbitrary agent handoff fields on an explicit pinote task. Read first with pinote_get; pass its updated_at as expected_updated_at. set merges label/value pairs without replacing other fields or task text; values are Markdown, e.g. PR: [Fix #42](https://github.com/org/repo/pull/42). Set PR to one GitHub pull-request URL or Markdown link to show it in the footer and watch for merge confirmation in interactive Pi. remove deletes named fields. A stale revision fails; read again before retrying. Does not complete the task.",
|
|
316
|
+
description: "Patch arbitrary agent handoff fields on an explicit pinote task. Read first with pinote_get; pass its updated_at as expected_updated_at. set merges label/value pairs without replacing other fields or task text; values are Markdown, e.g. PR: [Fix #42](https://github.com/org/repo/pull/42). Set PR to one GitHub pull-request URL or Markdown link to show it in the footer and watch for merge confirmation in interactive Pi. remove deletes named fields. A stale revision fails; read again before retrying. Does not complete the task or change its tag.",
|
|
297
317
|
parameters: Type.Object({
|
|
298
318
|
id: idSchema,
|
|
299
319
|
expected_updated_at: Type.String({ minLength: 1 }),
|
|
@@ -304,21 +324,65 @@ export default function (pi: ExtensionAPI) {
|
|
|
304
324
|
if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
|
|
305
325
|
throw new Error("id and expected_updated_at are required; read the task first.");
|
|
306
326
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
327
|
+
const argv = ["agent", "update", String(params.id), "--expected-updated-at", params.expected_updated_at,
|
|
328
|
+
"--set-json", JSON.stringify(params.set ?? {})];
|
|
329
|
+
for (const label of params.remove ?? []) argv.push(`--remove=${label}`);
|
|
330
|
+
return toolResult(await writeTask(ctx, signal, argv, params.id));
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
pi.registerTool({
|
|
334
|
+
name: "pinote_tags",
|
|
335
|
+
label: "Pinote tags",
|
|
336
|
+
description: "List saved pinote tag names so a new or retagged task can reuse one.",
|
|
337
|
+
parameters: Type.Object({}),
|
|
338
|
+
async execute(_toolCallId, _params, signal) {
|
|
339
|
+
const value = parse(await run(["agent", "tags"], signal, () => true, "0.4.0"));
|
|
340
|
+
if (!Array.isArray(value) || !value.every((item) => text(item))) throw new Error(compatible);
|
|
341
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value };
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
pi.registerTool({
|
|
345
|
+
name: "pinote_add",
|
|
346
|
+
label: "Pinote add",
|
|
347
|
+
promptGuidelines: createGuidance,
|
|
348
|
+
description: "Create an active pinote task. Optional tag is registered if new. Set select true only after the user agrees to make it this session's active task; that starts it and selects it for this project. Omitting select leaves the current selection unchanged. Does not refresh the desktop notification.",
|
|
349
|
+
parameters: Type.Object({
|
|
350
|
+
text: Type.String({ minLength: 1 }),
|
|
351
|
+
tag: Type.Optional(Type.String({ minLength: 1 })),
|
|
352
|
+
select: Type.Optional(Type.Boolean()),
|
|
353
|
+
}),
|
|
354
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
355
|
+
if (!text(params.text) || !params.text.trim()) throw new Error("text must be a non-empty note.");
|
|
356
|
+
if (params.tag !== undefined && (!text(params.tag) || !params.tag.trim())) {
|
|
357
|
+
throw new Error("tag must be a non-empty name, or omit it.");
|
|
358
|
+
}
|
|
359
|
+
if (params.select !== undefined && typeof params.select !== "boolean") throw new Error("select must be a boolean.");
|
|
360
|
+
if (params.select === true && !isAbsolute(ctx.cwd)) throw new Error("Project directory must be absolute before selecting a task.");
|
|
361
|
+
const argv = ["agent", "add", `--text=${params.text}`];
|
|
362
|
+
if (params.tag !== undefined) argv.push(`--tag=${params.tag}`);
|
|
363
|
+
if (params.select === true) argv.push("--cwd", ctx.cwd);
|
|
364
|
+
return toolResult(await writeTask(ctx, signal, argv, undefined, "0.4.0"));
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
pi.registerTool({
|
|
368
|
+
name: "pinote_tag",
|
|
369
|
+
label: "Pinote tag",
|
|
370
|
+
description: "Set or clear the tag on an active or in-progress pinote task. Read first with pinote_get and pass updated_at as expected_updated_at. Provide tag or clear true, not both. A stale revision fails without writing. Does not change task text.",
|
|
371
|
+
parameters: Type.Object({
|
|
372
|
+
id: idSchema,
|
|
373
|
+
expected_updated_at: Type.String({ minLength: 1 }),
|
|
374
|
+
tag: Type.Optional(Type.String({ minLength: 1 })),
|
|
375
|
+
clear: Type.Optional(Type.Boolean()),
|
|
376
|
+
}),
|
|
377
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
378
|
+
if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
|
|
379
|
+
throw new Error("id and expected_updated_at are required; read the task first.");
|
|
321
380
|
}
|
|
381
|
+
if ((params.tag !== undefined) === (params.clear === true)) throw new Error("Provide tag or clear true, not both.");
|
|
382
|
+
if (params.tag !== undefined && (!text(params.tag) || !params.tag.trim())) throw new Error("tag must be a non-empty name.");
|
|
383
|
+
const argv = ["agent", "tag", String(params.id), "--expected-updated-at", params.expected_updated_at];
|
|
384
|
+
argv.push(params.clear === true ? "--clear" : `--tag=${params.tag}`);
|
|
385
|
+
return toolResult(await writeTask(ctx, signal, argv, params.id, "0.4.0"));
|
|
322
386
|
},
|
|
323
387
|
});
|
|
324
388
|
}
|
package/package.json
CHANGED
package/setup.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
3
3
|
import type { AutocompleteProvider } from "@earendil-works/pi-tui";
|
|
4
4
|
|
|
5
5
|
// Immutable source: the Python app is not yet published to PyPI.
|
|
6
|
-
export const cliSource = "https://github.com/kvidzibo/pinote/archive/
|
|
7
|
-
export const bundledCLIVersion = "0.
|
|
6
|
+
export const cliSource = "https://github.com/kvidzibo/pinote/archive/1597baf60bff8bc96893be40781f152a001623d1.tar.gz";
|
|
7
|
+
export const bundledCLIVersion = "0.4.0";
|
|
8
8
|
export const setupHint = "Use /pi-note-setup for a missing CLI or /pi-note-upgrade for an older CLI; note must be on PATH.";
|
|
9
9
|
export type CLIAction = "setup" | "upgrade" | "ready";
|
|
10
10
|
|
|
@@ -44,9 +44,19 @@ export function cliMenu(current: AutocompleteProvider, action: () => CLIAction |
|
|
|
44
44
|
shouldTriggerFileCompletion: (...args) => current.shouldTriggerFileCompletion?.(...args) ?? true,
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
-
export function
|
|
47
|
+
export function versionAtLeast(version: string, minimum: string): boolean {
|
|
48
48
|
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/u.exec(version.trim());
|
|
49
|
-
|
|
49
|
+
if (!match) return false;
|
|
50
|
+
const installed = match.slice(1).map(Number);
|
|
51
|
+
const required = minimum.split(".").map(Number);
|
|
52
|
+
for (let i = 0; i < 3; i++) {
|
|
53
|
+
if (installed[i] > required[i]) return true;
|
|
54
|
+
if (installed[i] < required[i]) return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
export function compatibleVersion(version: string): boolean {
|
|
59
|
+
return versionAtLeast(version, "0.3.0");
|
|
50
60
|
}
|
|
51
61
|
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").slice(0, 1500);
|
|
52
62
|
|