pi-note 0.4.1 → 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 +15 -4
- package/index.ts +89 -23
- 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`.
|
|
@@ -77,9 +77,20 @@ before/after agent activity, and after commands/tools.
|
|
|
77
77
|
Agent tools:
|
|
78
78
|
|
|
79
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.
|
|
80
81
|
- `pinote_update`: supply `id`, `expected_updated_at` from that read, and arbitrary
|
|
81
82
|
`set` label/value pairs or `remove` labels. Updates merge fields, never replace
|
|
82
|
-
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.
|
|
83
94
|
|
|
84
95
|
Agents are guided to keep notes to three short bullets total: relevant outcome,
|
|
85
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
|
|
|
@@ -276,22 +297,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
276
297
|
pi.registerTool({
|
|
277
298
|
name: "pinote_get",
|
|
278
299
|
label: "Pinote get",
|
|
279
|
-
|
|
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.",
|
|
280
302
|
parameters: Type.Object({ id: Type.Optional(idSchema) }),
|
|
281
303
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
282
304
|
if (params.id !== undefined && !validId(params.id)) throw new Error("id must be a positive safe integer.");
|
|
283
305
|
const argv = params.id === undefined
|
|
284
306
|
? ["agent", "selected", "--cwd", ctx.cwd]
|
|
285
307
|
: ["agent", "get", String(params.id)];
|
|
286
|
-
const
|
|
287
|
-
return toolResult(
|
|
308
|
+
const raw = await run(argv, signal);
|
|
309
|
+
return toolResult(params.id === undefined ? task(raw) : requiredTask(raw, params.id));
|
|
288
310
|
},
|
|
289
311
|
});
|
|
290
312
|
pi.registerTool({
|
|
291
313
|
name: "pinote_update",
|
|
292
314
|
label: "Pinote update",
|
|
293
315
|
promptGuidelines: [handoffGuidance],
|
|
294
|
-
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.",
|
|
295
317
|
parameters: Type.Object({
|
|
296
318
|
id: idSchema,
|
|
297
319
|
expected_updated_at: Type.String({ minLength: 1 }),
|
|
@@ -302,21 +324,65 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
324
|
if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
|
|
303
325
|
throw new Error("id and expected_updated_at are required; read the task first.");
|
|
304
326
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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.");
|
|
319
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"));
|
|
320
386
|
},
|
|
321
387
|
});
|
|
322
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
|
|