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.
Files changed (4) hide show
  1. package/README.md +27 -10
  2. package/index.ts +170 -24
  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`.
@@ -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
- Selection is stored per canonical working directory in pinote's database. A new
72
- Pi session shows that selection; use **Continue** to insert its task-ID prompt.
73
- Completing, removing, or scheduling a task clears its selections. Switching does
74
- not complete or reset the previous task. Task status refreshes at session start,
75
- before/after agent activity, and after commands/tools.
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 the selected task, or an explicit `id`, including its revision.
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, compatibleVersion, setupCLI, setupHint, type CLIAction } from "./setup.ts";
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 run = async (argv: string[], signal?: AbortSignal, canRun = () => true) => {
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 (!compatibleVersion(version)) {
106
- throw new Error(`pi-note requires pinote 0.3.0+; found ${clean(version) || "an unknown CLI"}. ${setupHint}`);
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 selected = async (ctx: ExtensionContext, signal?: AbortSignal) =>
112
- task(await run(["agent", "selected", "--cwd", ctx.cwd], signal));
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 = requiredTask(await run(["agent", "select", String(id), "--cwd", ctx.cwd], undefined, canAct), id);
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
- 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.",
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 argv = params.id === undefined
284
- ? ["agent", "selected", "--cwd", ctx.cwd]
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 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);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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