pi-note 0.21.0 → 0.22.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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-note
2
2
 
3
+ [npm](https://www.npmjs.com/package/pi-note) · [Pi package directory](https://pi.dev/packages/pi-note)
4
+
3
5
  Persistent [pinote](https://github.com/kvidzibo/pinote) tasks and Markdown handoffs
4
6
  in Pi: selected-task footer status, `/pi-note`, and agent current-task read/update,
5
7
  add, and tag-listing tools.
@@ -12,10 +14,8 @@ explicit initial-version limits; it is not activated by installing this extensio
12
14
  ## Install
13
15
 
14
16
  Requires Pi 0.99.1+, Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH). Add and tag listing need **pinote 0.4.0+**.
15
- From this checkout, install the extension with `pi install ./pi`, then run
16
- `/reload` in interactive Pi. The planned npm package name is `pi-note`; after its
17
- first release use `pi install npm:pi-note` instead. It is not published yet. npm
18
- installation does not run Python installers.
17
+ Install with `pi install npm:pi-note`, or `pi install ./pi` from this checkout,
18
+ then run `/reload` in interactive Pi. npm installation does not run Python installers.
19
19
 
20
20
  ### CLI setup and upgrades
21
21
 
@@ -390,13 +390,13 @@ data. Run visual checks under Xvfb/private D-Bus; never use personal task data.
390
390
  OIDC trusted publishing and provenance. Existing versions are skipped; registry
391
391
  failures fail the workflow. No npm token is stored in GitHub.
392
392
 
393
- One-time maintainer setup (not performed by installing this package):
393
+ Only a package's first publication needs this manual bootstrap (not part of
394
+ installation). `pi-note` is already published; use automated releases above.
394
395
 
395
396
  1. From `pi/`, run `npm login`, then `npm publish --ignore-scripts --access public`
396
397
  to bootstrap `pi-note` after checking name availability.
397
398
  2. In npm's package settings configure the GitHub trusted publisher: owner
398
399
  `kvidzibo`, repository `pinote`, workflow `publish.yml`, no environment.
399
- 3. Add verified npm and Pi package-directory links here after publication.
400
400
 
401
401
  The CLI source pin in `setup.ts` must point to a verified immutable commit with a
402
402
  compatible Python package. Update it and the bundled-version setup text together
@@ -27,7 +27,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
27
27
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
28
28
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
29
29
  }) });
30
- const server = new Server({ name: 'pinote', version: '0.21.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
30
+ const server = new Server({ name: 'pinote', version: '0.22.1' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
31
31
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
32
32
  let queue = Promise.resolve();
33
33
  function serial(run) {
@@ -134,7 +134,10 @@ function actionRevision(data, control) {
134
134
  function staleAction() {
135
135
  throw new McpError(-33003, 'View changed; refresh before acting', { 'native-ui/action': { status: 'not-applied', reason: 'stale-view' } });
136
136
  }
137
- function view(data, scoped = false) {
137
+ function secondaryActions(meta) {
138
+ return z.object({ footerSecondaryActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
139
+ }
140
+ function view(data, scoped = false, secondary = false) {
138
141
  const { tasks, task, config, revision } = data;
139
142
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
140
143
  const controls = [];
@@ -147,7 +150,7 @@ function view(data, scoped = false) {
147
150
  action('done', 'Complete task', { icon: icons.check });
148
151
  }
149
152
  else if (state.proposal) {
150
- action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
153
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
151
154
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
152
155
  }
153
156
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -196,7 +199,7 @@ function view(data, scoped = false) {
196
199
  footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
197
200
  }
198
201
  else if (state.proposal) {
199
- footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
202
+ footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
200
203
  footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
201
204
  }
202
205
  else
@@ -253,7 +256,7 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
253
256
  if (request.params.uri !== URI)
254
257
  throw new Error('Unknown resource');
255
258
  const data = await snapshot(context(request.params._meta).state, extra.signal);
256
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta))) }] };
259
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
257
260
  }));
258
261
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
259
262
  const state = context(request.params._meta).state;
@@ -351,13 +354,13 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
351
354
  extra.signal.throwIfAborted();
352
355
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
353
356
  }
354
- const result = { view: view(await snapshot(next, extra.signal), scoped), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
357
+ const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
355
358
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
356
359
  return result;
357
360
  }));
358
361
  const empty = { type: 'object', properties: {}, additionalProperties: false };
359
362
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
360
- { name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object', properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
363
+ { name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user left-clicks + to create/start/select or right-clicks it to dismiss (also available in the app menu). Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object', properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
361
364
  { name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation before multi-step implementation or configuration migrations. Returns null if unselected; then use tags and propose to suggest a task without creating one. Skip informational questions and trivial edits; never replace an existing selection.', inputSchema: empty, annotations: { readOnlyHint: true } },
362
365
  { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
363
366
  { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
@@ -376,7 +379,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
376
379
  outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
377
380
  if (state.proposal && !await consumed(state.proposal.id))
378
381
  await consume(state.proposal.id);
379
- result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
382
+ result = { status: 'Suggested; user can left-click + to accept or right-click + to dismiss (or use the app menu). Continue working without asking again.' };
380
383
  }
381
384
  else if (request.params.name === 'get_current')
382
385
  result = await selected(state, extra.signal);
package/mcp/README.md CHANGED
@@ -18,11 +18,12 @@ npm --prefix pi ci --ignore-scripts
18
18
  npm --prefix pi run build:mcp
19
19
  ```
20
20
 
21
- Add a service to the gateway config (use absolute paths):
21
+ Merge the service into your gateway's existing `mcpServers` map, preserving other
22
+ services (use absolute paths). Do not mix `mcpServers` with the legacy `servers` key:
22
23
 
23
24
  ```json
24
25
  {
25
- "servers": {
26
+ "mcpServers": {
26
27
  "pinote": {
27
28
  "command": "node",
28
29
  "args": ["/absolute/path/to/pinote/pi/dist-mcp/mcp/server.js"],
@@ -55,10 +56,14 @@ normal personal data. No desktop notifications are sent.
55
56
  fails closed; repair it manually rather than replacing it with defaults.
56
57
  - **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
57
58
  The title previews the task; **✔** completes it and clears the selection without
58
- resetting the conversation or editor. Suggestions show **✖ / ✚** to dismiss or
59
- create/start/select. These use the gateway's generic footer actions, not a Pinote
60
- extension or private handler. Install the gateway's Kitty protocol handler as
61
- described in its native UI documentation; app menus remain the keyboard fallback.
59
+ resetting the conversation or editor. Suggestions show only **✚**: left-click to
60
+ create/start/select, right-click to dismiss. Right-click requires gateway 0.22.0+
61
+ and its Kitty mouse mapping; **Dismiss suggestion** in the app menu remains the
62
+ keyboard/older-host fallback. These use the gateway's generic footer actions, not a Pinote
63
+ extension or private handler. Install the gateway's Kitty protocol handler
64
+ and right-click mouse mapping as described in its native UI documentation. Existing
65
+ installations need to add the mapping and reload Kitty; package updates do not
66
+ edit terminal configuration. App menus remain the keyboard fallback.
62
67
  - **Agent:** `propose`, `get_current`, `update_current`, `fields`, and `tags` are
63
68
  available through gateway tool discovery. `propose` returns opaque UI state;
64
69
  it does not create a task. It replaces an unaccepted suggestion only when no task
package/mcp/server.ts CHANGED
@@ -32,7 +32,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
32
32
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
33
33
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
34
34
  }) });
35
- const server = new Server({ name: 'pinote', version: '0.21.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
35
+ const server = new Server({ name: 'pinote', version: '0.22.1' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
36
36
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
37
37
  let queue = Promise.resolve();
38
38
  function serial<T>(run: () => Promise<T>): Promise<T> {
@@ -115,7 +115,10 @@ function actionRevision(data: Snapshot, control: Control): string {
115
115
  function staleAction(): never {
116
116
  throw new McpError(-33003, 'View changed; refresh before acting', { 'native-ui/action': { status: 'not-applied', reason: 'stale-view' } });
117
117
  }
118
- function view(data: Snapshot, scoped = false) {
118
+ function secondaryActions(meta: Record<string, unknown> | undefined): boolean {
119
+ return z.object({ footerSecondaryActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
120
+ }
121
+ function view(data: Snapshot, scoped = false, secondary = false) {
119
122
  const { tasks, task, config, revision } = data;
120
123
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
121
124
  const controls: Control[] = [];
@@ -127,7 +130,7 @@ function view(data: Snapshot, scoped = false) {
127
130
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
128
131
  action('done', 'Complete task', { icon: icons.check });
129
132
  } else if (state.proposal) {
130
- action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
133
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
131
134
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
132
135
  }
133
136
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -161,12 +164,12 @@ function view(data: Snapshot, scoped = false) {
161
164
  for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, `${candidate.tag ? `[${candidate.tag}] ` : ''}${candidate.text.split('\n')[0]}`, { description: `#${candidate.id} · ${candidate.state}`, confirm: 'Select and start this task for this conversation?' });
162
165
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
163
166
  }
164
- const footer: { text: string; url?: string; action?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
167
+ const footer: { text: string; url?: string; action?: string; secondaryAction?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
165
168
  if (task) {
166
169
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
167
170
  footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
168
171
  } else if (state.proposal) {
169
- footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
172
+ footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
170
173
  footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
171
174
  } else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
172
175
  const titleParts = footer.length;
@@ -208,7 +211,7 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [
208
211
  server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
209
212
  if (request.params.uri !== URI) throw new Error('Unknown resource');
210
213
  const data = await snapshot(context(request.params._meta).state, extra.signal);
211
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta))) }] };
214
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
212
215
  }));
213
216
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
214
217
  const state = context(request.params._meta).state;
@@ -272,13 +275,13 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
272
275
  extra.signal.throwIfAborted();
273
276
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
274
277
  }
275
- const result = { view: view(await snapshot(next, extra.signal), scoped), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
278
+ const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
276
279
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
277
280
  return result;
278
281
  }));
279
282
  const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
280
283
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
281
- { name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object' as const, properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
284
+ { name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user left-clicks + to create/start/select or right-clicks it to dismiss (also available in the app menu). Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object' as const, properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
282
285
  { name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation before multi-step implementation or configuration migrations. Returns null if unselected; then use tags and propose to suggest a task without creating one. Skip informational questions and trivial edits; never replace an existing selection.', inputSchema: empty, annotations: { readOnlyHint: true } },
283
286
  { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
284
287
  { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
@@ -294,7 +297,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
294
297
  if (!args.text.trim()) throw new Error('Task text must not be blank');
295
298
  outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
296
299
  if (state.proposal && !await consumed(state.proposal.id)) await consume(state.proposal.id);
297
- result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
300
+ result = { status: 'Suggested; user can left-click + to accept or right-click + to dismiss (or use the app menu). Continue working without asking again.' };
298
301
  } else if (request.params.name === 'get_current') result = await selected(state, extra.signal);
299
302
  else if (request.params.name === 'fields') result = settings().config;
300
303
  else if (request.params.name === 'tags') result = JSON.parse(await note(['agent', 'tags'], extra.signal));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",