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.
Files changed (4) hide show
  1. package/README.md +15 -4
  2. package/index.ts +89 -23
  3. package/package.json +1 -1
  4. 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.3.0), without network requests:
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, compatibleVersion, setupCLI, setupHint, type CLIAction } from "./setup.ts";
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 (!compatibleVersion(version)) {
106
- throw new Error(`pi-note requires pinote 0.3.0+; found ${clean(version) || "an unknown CLI"}. ${setupHint}`);
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
- description: "Read a pinote task and its Markdown agent fields/revision. Omit id to read this project's selected task; supply id to read another task. Use the returned updated_at for pinote_update.",
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 result = requiredTask(await run(argv, signal), params.id);
287
- return toolResult(result);
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
- if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
306
- const operation = Symbol();
307
- pending = operation;
308
- const generation = epoch;
309
- refreshSerial++;
310
- try {
311
- const argv = ["agent", "update", String(params.id), "--expected-updated-at", params.expected_updated_at,
312
- "--set-json", JSON.stringify(params.set ?? {})];
313
- for (const label of params.remove ?? []) argv.push(`--remove=${label}`);
314
- const result = requiredTask(await run(argv, signal, () => alive && generation === epoch), params.id);
315
- return toolResult(result);
316
- } finally {
317
- if (pending === operation) pending = undefined;
318
- if (alive && generation === epoch) await refresh(ctx);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
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/9c6795f655e6b9d2c51c735fc84b6485fb663bb1.tar.gz";
7
- export const bundledCLIVersion = "0.3.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 compatibleVersion(version: string): boolean {
47
+ export function versionAtLeast(version: string, minimum: string): boolean {
48
48
  const match = /^pinote (\d+)\.(\d+)\.(\d+)$/u.exec(version.trim());
49
- return !!match && (Number(match[1]) > 0 || Number(match[2]) >= 3);
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