pi-note 0.4.1 → 0.5.1
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 +27 -10
- package/index.ts +170 -24
- 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`.
|
|
@@ -68,18 +68,35 @@ The normal Pi status area shows `📌 [tag] Task title` without replacing other
|
|
|
68
68
|
The footer omits the task ID and state and shows only the first line, truncated to 60 terminal columns including the pin and tag.
|
|
69
69
|
The pin glyph is bundled in `icons/note.txt`; it uses the terminal's emoji font, not a Nerd Font or icon theme.
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
Each Pi session remembers its own task, including several sessions in one folder.
|
|
72
|
+
Resume restores that session's task; a new session starts unselected and does not import
|
|
73
|
+
an older per-folder selection. Pi saves the session after the first submitted message;
|
|
74
|
+
quitting before that leaves the next launch unselected, while the task stays in progress.
|
|
75
|
+
Use **Continue**
|
|
76
|
+
to insert its task-ID prompt. Completing, removing, or scheduling a task clears it
|
|
77
|
+
from sessions that refresh it. Switching does not complete or reset the previous task.
|
|
78
|
+
Task fields such as `Worktree` and `PR` stay on the task, so sessions can use separate
|
|
79
|
+
worktrees and pull requests. Task status refreshes at session start, before/after
|
|
80
|
+
agent activity, and after commands/tools. The CLI's per-directory `agent selected`
|
|
81
|
+
command is not this session memory.
|
|
76
82
|
|
|
77
83
|
Agent tools:
|
|
78
84
|
|
|
79
|
-
- `pinote_get`: read
|
|
85
|
+
- `pinote_get`: read this session's selected task, or an explicit `id`, including its revision.
|
|
86
|
+
Omitting `id` returns null when this session has no selected task.
|
|
80
87
|
- `pinote_update`: supply `id`, `expected_updated_at` from that read, and arbitrary
|
|
81
88
|
`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.
|
|
89
|
+
the task text or tag. If another process changed the task, read again before retrying.
|
|
90
|
+
- `pinote_tags`: list saved tag names.
|
|
91
|
+
- `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
|
|
92
|
+
remembers it for this session only; use that only after the user agrees.
|
|
93
|
+
- `pinote_tag`: set or clear a tag on an active or in-progress task. Read first and
|
|
94
|
+
pass `expected_updated_at`.
|
|
95
|
+
|
|
96
|
+
When the user gives work and no task is selected, the agent proposes one note as
|
|
97
|
+
`[tag] text` and asks before creating it. No continues without a note. Yes calls
|
|
98
|
+
`pinote_add` with `select: true`. An existing selection is not replaced unless the
|
|
99
|
+
user asks to switch. Reuse a saved tag name when it fits.
|
|
83
100
|
|
|
84
101
|
Agents are guided to keep notes to three short bullets total: relevant outcome,
|
|
85
102
|
blocker, and next action. Replace stale notes; omit narration, repeated task text,
|
package/index.ts
CHANGED
|
@@ -5,9 +5,13 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { createPRWatcher } from "./pr-watch.ts";
|
|
7
7
|
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
8
|
-
import { bundledCLIVersion, cliMenu, detectCLI,
|
|
8
|
+
import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
|
|
9
9
|
|
|
10
10
|
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.";
|
|
11
|
+
const createGuidance = [
|
|
12
|
+
"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.",
|
|
13
|
+
"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.",
|
|
14
|
+
];
|
|
11
15
|
type Task = {
|
|
12
16
|
id: number;
|
|
13
17
|
text: string;
|
|
@@ -58,7 +62,8 @@ function taskList(raw: string): Summary[] {
|
|
|
58
62
|
return value.sort((a, b) => b.id - a.id);
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
function toolResult(value: Task) {
|
|
65
|
+
function toolResult(value: Task | null) {
|
|
66
|
+
if (!value) return { content: [{ type: "text" as const, text: "null" }], details: null };
|
|
62
67
|
// Keep the model payload under 50 KiB at the CLI's limits; don't send the same fields twice.
|
|
63
68
|
const { markdown: _markdown, ...data } = value;
|
|
64
69
|
return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: value };
|
|
@@ -97,19 +102,86 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
102
|
}
|
|
98
103
|
return result.stdout;
|
|
99
104
|
};
|
|
100
|
-
const
|
|
105
|
+
const ensureCLI = async (signal?: AbortSignal, canRun = () => true, minimum = "0.3.0") => {
|
|
101
106
|
if (setupAbort) throw new Error("Pinote CLI setup is still running. Retry after it finishes.");
|
|
102
107
|
// Old pinote treats unknown commands as note text. Probe safely before every call,
|
|
103
108
|
// including tools in headless sessions, and do not cache across CLI upgrades/downgrades.
|
|
104
109
|
const version = (await invoke(["--version"], signal)).trim();
|
|
105
|
-
if (!
|
|
106
|
-
throw new Error(`pi-note requires pinote
|
|
110
|
+
if (!versionAtLeast(version, minimum)) {
|
|
111
|
+
throw new Error(`pi-note requires pinote ${minimum}+; found ${clean(version) || "an unknown CLI"}. ${setupHint}`);
|
|
107
112
|
}
|
|
108
113
|
if (!canRun()) throw new Error("Pinote operation cancelled: the session changed.");
|
|
114
|
+
};
|
|
115
|
+
const run = async (argv: string[], signal?: AbortSignal, canRun = () => true, minimum = "0.3.0") => {
|
|
116
|
+
await ensureCLI(signal, canRun, minimum);
|
|
109
117
|
return invoke(argv, signal);
|
|
110
118
|
};
|
|
111
|
-
const
|
|
112
|
-
|
|
119
|
+
const writeTask = async (
|
|
120
|
+
ctx: ExtensionContext, signal: AbortSignal | undefined, argv: string[], id?: number, minimum = "0.3.0",
|
|
121
|
+
) => {
|
|
122
|
+
if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
|
|
123
|
+
const operation = Symbol();
|
|
124
|
+
pending = operation;
|
|
125
|
+
const generation = epoch;
|
|
126
|
+
refreshSerial++;
|
|
127
|
+
try {
|
|
128
|
+
return requiredTask(await run(argv, signal, () => alive && generation === epoch, minimum), id);
|
|
129
|
+
} finally {
|
|
130
|
+
if (pending === operation) pending = undefined;
|
|
131
|
+
if (alive && generation === epoch) await refresh(ctx);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
// One folder can host several sessions. Remember the task in the Pi session, not by cwd.
|
|
135
|
+
const selectionType = "pinote-selection";
|
|
136
|
+
let selectedId: number | null = null;
|
|
137
|
+
let branchEpoch = 0;
|
|
138
|
+
const remember = (id: number | null, branch = branchEpoch) => {
|
|
139
|
+
if (!alive || branch !== branchEpoch || selectedId === id) return;
|
|
140
|
+
selectedId = id;
|
|
141
|
+
pi.appendEntry(selectionType, { id });
|
|
142
|
+
};
|
|
143
|
+
const readSelection = (ctx: ExtensionContext) => {
|
|
144
|
+
let id: number | null = null;
|
|
145
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
146
|
+
if (entry.type !== "custom" || entry.customType !== selectionType) continue;
|
|
147
|
+
const data = entry.data as { id?: unknown } | null;
|
|
148
|
+
id = data && validId(data.id) ? data.id : null;
|
|
149
|
+
}
|
|
150
|
+
return id;
|
|
151
|
+
};
|
|
152
|
+
const currentTask = async (signal?: AbortSignal, canUse = () => alive) => {
|
|
153
|
+
const id = selectedId;
|
|
154
|
+
if (!validId(id) || !canUse()) return null;
|
|
155
|
+
const current = task(await run(["agent", "get", String(id)], signal, canUse));
|
|
156
|
+
if (!canUse() || selectedId !== id) return null;
|
|
157
|
+
if (!current || current.id !== id || !["active", "in_progress"].includes(current.state)) {
|
|
158
|
+
if (canUse() && selectedId === id) remember(null);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return current;
|
|
162
|
+
};
|
|
163
|
+
const selected = async (_ctx: ExtensionContext, signal?: AbortSignal) => {
|
|
164
|
+
const generation = epoch;
|
|
165
|
+
const canUse = () => alive && generation === epoch;
|
|
166
|
+
await ensureCLI(signal, canUse);
|
|
167
|
+
return currentTask(signal, canUse);
|
|
168
|
+
};
|
|
169
|
+
const startTask = async (id: number, canAct: () => boolean, signal?: AbortSignal) => {
|
|
170
|
+
const branch = branchEpoch;
|
|
171
|
+
const still = () => canAct() && branch === branchEpoch;
|
|
172
|
+
const before = requiredTask(await run(["agent", "get", String(id)], signal, still), id);
|
|
173
|
+
if (!still()) return;
|
|
174
|
+
if (!["active", "in_progress"].includes(before.state)) {
|
|
175
|
+
throw new Error(`Note ${id} is ${before.state}; restore it first.`);
|
|
176
|
+
}
|
|
177
|
+
// Start without the shared cwd selection so another session can keep a different task.
|
|
178
|
+
if (before.state === "active") await run(["--no-notify", "start", String(id)], signal, still);
|
|
179
|
+
const chosen = requiredTask(await run(["agent", "get", String(id)], signal, still), id);
|
|
180
|
+
if (chosen.state !== "in_progress") throw new Error(compatible);
|
|
181
|
+
if (!still()) return;
|
|
182
|
+
remember(chosen.id, branch);
|
|
183
|
+
return chosen;
|
|
184
|
+
};
|
|
113
185
|
|
|
114
186
|
const refresh = async (ctx: ExtensionContext) => {
|
|
115
187
|
if (!alive || setupAbort || !ctx.hasUI || ctx.mode !== "tui") return;
|
|
@@ -149,6 +221,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
149
221
|
alive = true;
|
|
150
222
|
activeContext = ctx;
|
|
151
223
|
epoch++;
|
|
224
|
+
branchEpoch++;
|
|
225
|
+
selectedId = readSelection(ctx);
|
|
152
226
|
const generation = epoch;
|
|
153
227
|
cliState = undefined;
|
|
154
228
|
if (ctx.hasUI && ctx.mode === "tui") {
|
|
@@ -161,13 +235,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
161
235
|
if (!setupAbort) watcher.start(ctx);
|
|
162
236
|
await refresh(ctx);
|
|
163
237
|
});
|
|
238
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
239
|
+
if (!alive) return;
|
|
240
|
+
branchEpoch++;
|
|
241
|
+
const generation = branchEpoch;
|
|
242
|
+
const id = readSelection(ctx);
|
|
243
|
+
if (!alive || generation !== branchEpoch) return;
|
|
244
|
+
selectedId = id;
|
|
245
|
+
if (setupAbort) return;
|
|
246
|
+
await refresh(ctx);
|
|
247
|
+
});
|
|
164
248
|
pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
|
|
165
249
|
pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
|
|
166
250
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
167
251
|
setupAbort?.abort();
|
|
168
252
|
alive = false;
|
|
169
253
|
activeContext = undefined;
|
|
254
|
+
selectedId = null;
|
|
170
255
|
epoch++;
|
|
256
|
+
branchEpoch++;
|
|
171
257
|
refreshSerial++;
|
|
172
258
|
watcher.stop();
|
|
173
259
|
if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
|
|
@@ -226,8 +312,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
226
312
|
const operation = Symbol();
|
|
227
313
|
pending = operation;
|
|
228
314
|
const generation = epoch;
|
|
315
|
+
const branch = branchEpoch;
|
|
229
316
|
const currentSession = () => alive && generation === epoch;
|
|
230
|
-
const canAct = () => currentSession() && ctx.isIdle();
|
|
317
|
+
const canAct = () => currentSession() && ctx.isIdle() && branch === branchEpoch;
|
|
231
318
|
try {
|
|
232
319
|
const current = await selected(ctx);
|
|
233
320
|
if (!canAct()) return;
|
|
@@ -241,6 +328,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
241
328
|
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
242
329
|
], undefined, canAct), current.id);
|
|
243
330
|
if (completed.state !== "done") throw new Error(compatible);
|
|
331
|
+
if (currentSession() && branch === branchEpoch) remember(null, branch);
|
|
244
332
|
if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
|
|
245
333
|
return;
|
|
246
334
|
}
|
|
@@ -256,10 +344,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
256
344
|
if (choice === undefined || !canAct()) return;
|
|
257
345
|
id = choice;
|
|
258
346
|
} else return;
|
|
259
|
-
// Validate/start the explicit task and return its latest handoff in one transaction.
|
|
260
347
|
refreshSerial++;
|
|
261
|
-
const chosen =
|
|
262
|
-
if (!canAct()) return;
|
|
348
|
+
const chosen = await startTask(id, canAct);
|
|
349
|
+
if (!chosen || !canAct()) return;
|
|
263
350
|
const draft = ctx.ui.getEditorText();
|
|
264
351
|
const handoff = `Read task #${chosen.id} with pinote_get and work on it. Ask only if blocked. Save progress with pinote_update.`;
|
|
265
352
|
ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
|
|
@@ -276,14 +363,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
276
363
|
pi.registerTool({
|
|
277
364
|
name: "pinote_get",
|
|
278
365
|
label: "Pinote get",
|
|
279
|
-
|
|
366
|
+
promptGuidelines: createGuidance,
|
|
367
|
+
description: "Read a pinote task and its Markdown agent fields/revision. Omit id to read this session'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
368
|
parameters: Type.Object({ id: Type.Optional(idSchema) }),
|
|
281
369
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
282
370
|
if (params.id !== undefined && !validId(params.id)) throw new Error("id must be a positive safe integer.");
|
|
283
|
-
const
|
|
284
|
-
?
|
|
285
|
-
: ["agent", "get", String(params.id)];
|
|
286
|
-
const result = requiredTask(await run(argv, signal), params.id);
|
|
371
|
+
const result = params.id === undefined
|
|
372
|
+
? await selected(ctx, signal)
|
|
373
|
+
: requiredTask(await run(["agent", "get", String(params.id)], signal), params.id);
|
|
287
374
|
return toolResult(result);
|
|
288
375
|
},
|
|
289
376
|
});
|
|
@@ -291,7 +378,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
291
378
|
name: "pinote_update",
|
|
292
379
|
label: "Pinote update",
|
|
293
380
|
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.",
|
|
381
|
+
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
382
|
parameters: Type.Object({
|
|
296
383
|
id: idSchema,
|
|
297
384
|
expected_updated_at: Type.String({ minLength: 1 }),
|
|
@@ -302,21 +389,80 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
389
|
if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
|
|
303
390
|
throw new Error("id and expected_updated_at are required; read the task first.");
|
|
304
391
|
}
|
|
392
|
+
const argv = ["agent", "update", String(params.id), "--expected-updated-at", params.expected_updated_at,
|
|
393
|
+
"--set-json", JSON.stringify(params.set ?? {})];
|
|
394
|
+
for (const label of params.remove ?? []) argv.push(`--remove=${label}`);
|
|
395
|
+
return toolResult(await writeTask(ctx, signal, argv, params.id));
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
pi.registerTool({
|
|
399
|
+
name: "pinote_tags",
|
|
400
|
+
label: "Pinote tags",
|
|
401
|
+
description: "List saved pinote tag names so a new or retagged task can reuse one.",
|
|
402
|
+
parameters: Type.Object({}),
|
|
403
|
+
async execute(_toolCallId, _params, signal) {
|
|
404
|
+
const value = parse(await run(["agent", "tags"], signal, () => true, "0.4.0"));
|
|
405
|
+
if (!Array.isArray(value) || !value.every((item) => text(item))) throw new Error(compatible);
|
|
406
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value };
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
pi.registerTool({
|
|
410
|
+
name: "pinote_add",
|
|
411
|
+
label: "Pinote add",
|
|
412
|
+
promptGuidelines: createGuidance,
|
|
413
|
+
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 remembers it for this session only. Omitting select leaves the current selection unchanged. Does not refresh the desktop notification or bind the project directory.",
|
|
414
|
+
parameters: Type.Object({
|
|
415
|
+
text: Type.String({ minLength: 1 }),
|
|
416
|
+
tag: Type.Optional(Type.String({ minLength: 1 })),
|
|
417
|
+
select: Type.Optional(Type.Boolean()),
|
|
418
|
+
}),
|
|
419
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
420
|
+
if (!text(params.text) || !params.text.trim()) throw new Error("text must be a non-empty note.");
|
|
421
|
+
if (params.tag !== undefined && (!text(params.tag) || !params.tag.trim())) {
|
|
422
|
+
throw new Error("tag must be a non-empty name, or omit it.");
|
|
423
|
+
}
|
|
424
|
+
if (params.select !== undefined && typeof params.select !== "boolean") throw new Error("select must be a boolean.");
|
|
425
|
+
const argv = ["agent", "add", `--text=${params.text}`];
|
|
426
|
+
if (params.tag !== undefined) argv.push(`--tag=${params.tag}`);
|
|
427
|
+
const generation = epoch;
|
|
428
|
+
const branch = branchEpoch;
|
|
429
|
+
const created = await writeTask(ctx, signal, argv, undefined, "0.4.0");
|
|
430
|
+
if (params.select !== true) return toolResult(created);
|
|
431
|
+
if (!alive || generation !== epoch || branch !== branchEpoch) throw new Error("Pinote operation cancelled: the session changed.");
|
|
305
432
|
if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
|
|
306
433
|
const operation = Symbol();
|
|
307
434
|
pending = operation;
|
|
308
|
-
const generation = epoch;
|
|
309
|
-
refreshSerial++;
|
|
310
435
|
try {
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
return toolResult(
|
|
436
|
+
const chosen = await startTask(
|
|
437
|
+
created.id, () => alive && generation === epoch && branch === branchEpoch && pending === operation, signal,
|
|
438
|
+
);
|
|
439
|
+
if (!chosen) throw new Error("Pinote operation cancelled: the session changed.");
|
|
440
|
+
return toolResult(chosen);
|
|
316
441
|
} finally {
|
|
317
442
|
if (pending === operation) pending = undefined;
|
|
318
443
|
if (alive && generation === epoch) await refresh(ctx);
|
|
319
444
|
}
|
|
320
445
|
},
|
|
321
446
|
});
|
|
447
|
+
pi.registerTool({
|
|
448
|
+
name: "pinote_tag",
|
|
449
|
+
label: "Pinote tag",
|
|
450
|
+
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.",
|
|
451
|
+
parameters: Type.Object({
|
|
452
|
+
id: idSchema,
|
|
453
|
+
expected_updated_at: Type.String({ minLength: 1 }),
|
|
454
|
+
tag: Type.Optional(Type.String({ minLength: 1 })),
|
|
455
|
+
clear: Type.Optional(Type.Boolean()),
|
|
456
|
+
}),
|
|
457
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
458
|
+
if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
|
|
459
|
+
throw new Error("id and expected_updated_at are required; read the task first.");
|
|
460
|
+
}
|
|
461
|
+
if ((params.tag !== undefined) === (params.clear === true)) throw new Error("Provide tag or clear true, not both.");
|
|
462
|
+
if (params.tag !== undefined && (!text(params.tag) || !params.tag.trim())) throw new Error("tag must be a non-empty name.");
|
|
463
|
+
const argv = ["agent", "tag", String(params.id), "--expected-updated-at", params.expected_updated_at];
|
|
464
|
+
argv.push(params.clear === true ? "--clear" : `--tag=${params.tag}`);
|
|
465
|
+
return toolResult(await writeTask(ctx, signal, argv, params.id, "0.4.0"));
|
|
466
|
+
},
|
|
467
|
+
});
|
|
322
468
|
}
|
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
|
|