pi-note 0.20.1 → 0.22.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/dist-mcp/mcp/server.js +87 -22
- package/mcp/README.md +23 -7
- package/mcp/server.ts +77 -20
- package/package.json +1 -1
package/dist-mcp/mcp/server.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { readFileSync, watchFile, unwatchFile } from 'node:fs';
|
|
8
|
+
import { getAgentDir } from "../config-path.js";
|
|
8
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from "../footer-config.js";
|
|
13
14
|
const URI = 'pinote://ui';
|
|
@@ -26,7 +27,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
|
|
|
26
27
|
uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
|
|
27
28
|
value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
|
|
28
29
|
}) });
|
|
29
|
-
const server = new Server({ name: 'pinote', version: '0.
|
|
30
|
+
const server = new Server({ name: 'pinote', version: '0.22.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
|
|
30
31
|
// Serialize complete read/check/write cycles even when a host sends parallel calls.
|
|
31
32
|
let queue = Promise.resolve();
|
|
32
33
|
function serial(run) {
|
|
@@ -73,8 +74,8 @@ async function note(args, signal) {
|
|
|
73
74
|
const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
|
|
74
75
|
const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
|
|
75
76
|
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
76
|
-
if (!match || Number(match[1]) === 0 && Number(match[2]) <
|
|
77
|
-
throw new Error('Pinote MCP requires note 0.
|
|
77
|
+
if (!match || Number(match[1]) === 0 && Number(match[2]) < 12)
|
|
78
|
+
throw new Error('Pinote MCP requires note 0.12.0+');
|
|
78
79
|
return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
|
|
79
80
|
}
|
|
80
81
|
async function selected(state, signal) {
|
|
@@ -115,7 +116,28 @@ async function snapshot(state, signal) {
|
|
|
115
116
|
const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
|
|
116
117
|
return { state, tasks, task, config, revision };
|
|
117
118
|
}
|
|
118
|
-
function
|
|
119
|
+
function scopedActions(meta) {
|
|
120
|
+
return z.object({ actionRevisions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
|
|
121
|
+
}
|
|
122
|
+
function actionRevision(data, control) {
|
|
123
|
+
const { id, revision: _revision, ...semantics } = control;
|
|
124
|
+
let dependency = null;
|
|
125
|
+
if (id === 'done')
|
|
126
|
+
dependency = data.task && { id: data.task.id, updated_at: data.task.updated_at };
|
|
127
|
+
else if (id.startsWith('select:'))
|
|
128
|
+
dependency = data.tasks.find((task) => task.id === Number(id.slice(7)));
|
|
129
|
+
else if (!['preview', 'continue', 'clear', 'search', 'add', 'proposal:accept', 'proposal:dismiss'].includes(id) && !id.startsWith('screen:') && !id.startsWith('field:')) {
|
|
130
|
+
dependency = { raw: data.config.raw, ...(['add-field', 'remove-field', 'label', 'format', 'width', 'link'].includes(id) ? { fields: fields(data.config.config, data.task?.agent_notes) } : {}) };
|
|
131
|
+
}
|
|
132
|
+
return createHash('sha256').update(JSON.stringify({ action: id, semantics, state: data.state, dependency })).digest('hex');
|
|
133
|
+
}
|
|
134
|
+
function staleAction() {
|
|
135
|
+
throw new McpError(-33003, 'View changed; refresh before acting', { 'native-ui/action': { status: 'not-applied', reason: 'stale-view' } });
|
|
136
|
+
}
|
|
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) {
|
|
119
141
|
const { tasks, task, config, revision } = data;
|
|
120
142
|
const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
|
|
121
143
|
const controls = [];
|
|
@@ -128,7 +150,7 @@ function view(data) {
|
|
|
128
150
|
action('done', 'Complete task', { icon: icons.check });
|
|
129
151
|
}
|
|
130
152
|
else if (state.proposal) {
|
|
131
|
-
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}` });
|
|
132
154
|
action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
|
|
133
155
|
}
|
|
134
156
|
action('screen:tasks', 'Tasks', { icon: icons.note });
|
|
@@ -177,7 +199,7 @@ function view(data) {
|
|
|
177
199
|
footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
|
|
178
200
|
}
|
|
179
201
|
else if (state.proposal) {
|
|
180
|
-
footer.push({ text: `${icons.
|
|
202
|
+
footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
|
|
181
203
|
footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
|
|
182
204
|
}
|
|
183
205
|
else
|
|
@@ -207,6 +229,9 @@ function view(data) {
|
|
|
207
229
|
body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
|
|
208
230
|
return false;
|
|
209
231
|
});
|
|
232
|
+
if (scoped)
|
|
233
|
+
for (const control of editable)
|
|
234
|
+
control.revision = actionRevision(data, control);
|
|
210
235
|
const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
|
|
211
236
|
// JSON escaping and expanded footer formats can exceed the host budget even
|
|
212
237
|
// with bounded row counts. Preserve navigation/filter controls ahead of rows.
|
|
@@ -231,16 +256,16 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
|
|
|
231
256
|
if (request.params.uri !== URI)
|
|
232
257
|
throw new Error('Unknown resource');
|
|
233
258
|
const data = await snapshot(context(request.params._meta).state, extra.signal);
|
|
234
|
-
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
|
|
259
|
+
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
|
|
235
260
|
}));
|
|
236
261
|
server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
237
262
|
const state = context(request.params._meta).state;
|
|
238
263
|
const before = await snapshot(state, extra.signal);
|
|
239
|
-
|
|
240
|
-
throw new Error('View changed; refresh before acting');
|
|
264
|
+
const scoped = scopedActions(request.params._meta);
|
|
241
265
|
const { action, value } = request.params;
|
|
242
|
-
|
|
243
|
-
|
|
266
|
+
const control = view(before, scoped).controls.find((item) => item.id === action);
|
|
267
|
+
if (!control || (control.revision ?? before.revision) !== request.params.revision)
|
|
268
|
+
staleAction();
|
|
244
269
|
const next = { ...before.state };
|
|
245
270
|
let draft, preview;
|
|
246
271
|
if (action === 'proposal:accept' || action === 'proposal:dismiss') {
|
|
@@ -251,7 +276,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
251
276
|
next.proposal = null;
|
|
252
277
|
if (action === 'proposal:accept') {
|
|
253
278
|
const task = JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
|
|
254
|
-
await note(['start', String(task.id)], extra.signal);
|
|
279
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
|
|
255
280
|
next.selectedId = task.id;
|
|
256
281
|
}
|
|
257
282
|
}
|
|
@@ -271,11 +296,15 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
271
296
|
preview = (before.task.text + '\n\n' + JSON.stringify(before.task.agent_notes, null, 2)).slice(0, 20_000);
|
|
272
297
|
else if (action.startsWith('select:')) {
|
|
273
298
|
const id = Number(action.slice(7));
|
|
274
|
-
|
|
275
|
-
|
|
299
|
+
const candidate = before.tasks.find((task) => task.id === id);
|
|
300
|
+
if (!candidate)
|
|
301
|
+
staleAction();
|
|
302
|
+
const target = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
|
|
303
|
+
if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag)
|
|
304
|
+
staleAction();
|
|
276
305
|
if (next.proposal)
|
|
277
306
|
await consume(next.proposal.id);
|
|
278
|
-
await note(['start', String(id)], extra.signal);
|
|
307
|
+
await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal);
|
|
279
308
|
next.selectedId = id;
|
|
280
309
|
next.proposal = null;
|
|
281
310
|
}
|
|
@@ -284,7 +313,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
284
313
|
await consume(next.proposal.id);
|
|
285
314
|
next.proposal = null;
|
|
286
315
|
const task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
|
|
287
|
-
await note(['start', String(task.id)], extra.signal);
|
|
316
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
|
|
288
317
|
next.selectedId = task.id;
|
|
289
318
|
}
|
|
290
319
|
else if (action === 'done') {
|
|
@@ -325,13 +354,13 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
325
354
|
extra.signal.throwIfAborted();
|
|
326
355
|
saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
|
|
327
356
|
}
|
|
328
|
-
const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
|
|
357
|
+
const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
|
|
329
358
|
await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
|
|
330
359
|
return result;
|
|
331
360
|
}));
|
|
332
361
|
const empty = { type: 'object', properties: {}, additionalProperties: false };
|
|
333
362
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
|
|
334
|
-
{ 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 } },
|
|
335
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 } },
|
|
336
365
|
{ name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
337
366
|
{ name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
@@ -350,7 +379,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
350
379
|
outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
|
|
351
380
|
if (state.proposal && !await consumed(state.proposal.id))
|
|
352
381
|
await consume(state.proposal.id);
|
|
353
|
-
result = { status: 'Suggested; user can accept
|
|
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.' };
|
|
354
383
|
}
|
|
355
384
|
else if (request.params.name === 'get_current')
|
|
356
385
|
result = await selected(state, extra.signal);
|
|
@@ -374,4 +403,40 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
374
403
|
return { isError: true, content: [{ type: 'text', text: String(error) }] };
|
|
375
404
|
}
|
|
376
405
|
}));
|
|
406
|
+
// Every MCP process observes shared storage, including writes by CLI/GTK/other
|
|
407
|
+
// sessions. Stat polling survives missing files and atomic config replacement.
|
|
408
|
+
server.oninitialized = () => {
|
|
409
|
+
server.oninitialized = undefined;
|
|
410
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
411
|
+
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
412
|
+
const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
|
|
413
|
+
let timer;
|
|
414
|
+
let closed = false;
|
|
415
|
+
const changed = () => {
|
|
416
|
+
if (closed || timer)
|
|
417
|
+
return;
|
|
418
|
+
timer = setTimeout(() => {
|
|
419
|
+
timer = undefined;
|
|
420
|
+
void server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } }).catch((error) => {
|
|
421
|
+
if (!closed)
|
|
422
|
+
console.error('Pinote UI update failed:', error);
|
|
423
|
+
});
|
|
424
|
+
}, 100);
|
|
425
|
+
timer.unref();
|
|
426
|
+
};
|
|
427
|
+
const listeners = paths.map((path) => {
|
|
428
|
+
const listener = (current, previous) => {
|
|
429
|
+
if (current.mtimeMs !== previous.mtimeMs || current.ctimeMs !== previous.ctimeMs || current.size !== previous.size || current.ino !== previous.ino || current.nlink !== previous.nlink)
|
|
430
|
+
changed();
|
|
431
|
+
};
|
|
432
|
+
watchFile(path, { persistent: false, interval: 1000 }, listener);
|
|
433
|
+
return listener;
|
|
434
|
+
});
|
|
435
|
+
server.onclose = () => {
|
|
436
|
+
closed = true;
|
|
437
|
+
if (timer)
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
paths.forEach((path, index) => unwatchFile(path, listeners[index]));
|
|
440
|
+
};
|
|
441
|
+
};
|
|
377
442
|
await server.connect(new StdioServerTransport());
|
package/mcp/README.md
CHANGED
|
@@ -10,7 +10,7 @@ the built-in Pi MCP resource renderer. Use gateway 0.12.0+ with its Pi adapter.
|
|
|
10
10
|
|
|
11
11
|
## Setup
|
|
12
12
|
|
|
13
|
-
Requires Node.js 22.19+, Python 3.11+, and `note` 0.
|
|
13
|
+
Requires Node.js 22.19+, Python 3.11+, and `note` 0.12.0+. From the Pinote checkout:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
uv sync --locked
|
|
@@ -55,10 +55,14 @@ normal personal data. No desktop notifications are sent.
|
|
|
55
55
|
fails closed; repair it manually rather than replacing it with defaults.
|
|
56
56
|
- **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
|
|
57
57
|
The title previews the task; **✔** completes it and clears the selection without
|
|
58
|
-
resetting the conversation or editor. Suggestions show
|
|
59
|
-
create/start/select
|
|
58
|
+
resetting the conversation or editor. Suggestions show only **✚**: left-click to
|
|
59
|
+
create/start/select, right-click to dismiss. Right-click requires gateway 0.22.0+
|
|
60
|
+
and its Kitty mouse mapping; **Dismiss suggestion** in the app menu remains the
|
|
61
|
+
keyboard/older-host fallback. These use the gateway's generic footer actions, not a Pinote
|
|
60
62
|
extension or private handler. Install the gateway's Kitty protocol handler as
|
|
61
|
-
described in its native UI documentation
|
|
63
|
+
and right-click mouse mapping described in its native UI documentation. Existing
|
|
64
|
+
installations need to add the mapping and reload Kitty; package updates do not
|
|
65
|
+
edit terminal configuration. App menus remain the keyboard fallback.
|
|
62
66
|
- **Agent:** `propose`, `get_current`, `update_current`, `fields`, and `tags` are
|
|
63
67
|
available through gateway tool discovery. `propose` returns opaque UI state;
|
|
64
68
|
it does not create a task. It replaces an unaccepted suggestion only when no task
|
|
@@ -106,9 +110,21 @@ to reject historical proposals. Do not delete them while old sessions may resume
|
|
|
106
110
|
They are replay protection, not a second selection database or an authorization
|
|
107
111
|
boundary against local processes.
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
Each MCP process observes the shared database (including its WAL), settings, and
|
|
114
|
+
proposal receipts with coalesced, one-second stat checks. External CLI/GTK/other-session
|
|
115
|
+
changes refresh connected hosts; Ctrl+R or reopening the app remains a fallback.
|
|
116
|
+
Watchers stop on disconnect and never invoke the CLI or change task data.
|
|
117
|
+
|
|
118
|
+
Gateway 0.16.0+ negotiates per-control revisions: unrelated task-list changes no
|
|
119
|
+
longer invalidate preview/navigation, task creation, settings saves, or completion
|
|
120
|
+
of an unchanged selected task. Completion checks the displayed task revision;
|
|
121
|
+
selection starts its target with an atomic revision check. Settings writes check
|
|
122
|
+
the original file and resolved field definitions, including task-defined `Bar`
|
|
123
|
+
fields. Older hosts retain full-view revision checks without extra control fields.
|
|
124
|
+
|
|
125
|
+
Known stale-view rejections occur before writes and report **Action not applied**
|
|
126
|
+
on supporting hosts. Failed actions are never retried automatically: other errors
|
|
127
|
+
can follow a committed or partial write. Read fresh state before retrying.
|
|
112
128
|
Trusted services/agents have ordinary OS permissions; UI-only routing is not a
|
|
113
129
|
secrecy or authorization boundary.
|
|
114
130
|
|
package/mcp/server.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { readFileSync, watchFile, unwatchFile } from 'node:fs';
|
|
8
|
+
import { getAgentDir } from '../config-path.ts';
|
|
8
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from '../footer-config.ts';
|
|
13
14
|
|
|
@@ -26,12 +27,12 @@ const Context = z.object({ conversationId: z.string().min(1).max(256), contextId
|
|
|
26
27
|
type State = z.infer<typeof Context>['state'];
|
|
27
28
|
type Task = { id: number; text: string; state: string; tag: string | null; updated_at: string; agent_notes: Record<string, string> };
|
|
28
29
|
type Scalar = string | number | boolean | null;
|
|
29
|
-
type Control = { id: string; label: string; kind: 'action' | 'text' | 'number' | 'toggle' | 'select'; value?: Scalar; icon?: string; description?: string; multiline?: boolean; confirm?: string; options?: { label: string; value: Scalar }[] };
|
|
30
|
+
type Control = { id: string; label: string; revision?: string; kind: 'action' | 'text' | 'number' | 'toggle' | 'select'; value?: Scalar; icon?: string; description?: string; multiline?: boolean; confirm?: string; options?: { label: string; value: Scalar }[] };
|
|
30
31
|
const Action = z.object({ method: z.literal('native-ui/action'), params: z.object({
|
|
31
32
|
uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
|
|
32
33
|
value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
|
|
33
34
|
}) });
|
|
34
|
-
const server = new Server({ name: 'pinote', version: '0.
|
|
35
|
+
const server = new Server({ name: 'pinote', version: '0.22.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
|
|
35
36
|
// Serialize complete read/check/write cycles even when a host sends parallel calls.
|
|
36
37
|
let queue = Promise.resolve();
|
|
37
38
|
function serial<T>(run: () => Promise<T>): Promise<T> {
|
|
@@ -63,7 +64,7 @@ async function note(args: string[], signal: AbortSignal): Promise<string> {
|
|
|
63
64
|
const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
|
|
64
65
|
const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
|
|
65
66
|
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
66
|
-
if (!match || Number(match[1]) === 0 && Number(match[2]) <
|
|
67
|
+
if (!match || Number(match[1]) === 0 && Number(match[2]) < 12) throw new Error('Pinote MCP requires note 0.12.0+');
|
|
67
68
|
return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
|
|
68
69
|
}
|
|
69
70
|
async function selected(state: State, signal: AbortSignal): Promise<Task | null> {
|
|
@@ -98,7 +99,26 @@ async function snapshot(state: State, signal: AbortSignal) {
|
|
|
98
99
|
return { state, tasks, task, config, revision };
|
|
99
100
|
}
|
|
100
101
|
type Snapshot = Awaited<ReturnType<typeof snapshot>>;
|
|
101
|
-
function
|
|
102
|
+
function scopedActions(meta: Record<string, unknown> | undefined): boolean {
|
|
103
|
+
return z.object({ actionRevisions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
|
|
104
|
+
}
|
|
105
|
+
function actionRevision(data: Snapshot, control: Control): string {
|
|
106
|
+
const { id, revision: _revision, ...semantics } = control;
|
|
107
|
+
let dependency: unknown = null;
|
|
108
|
+
if (id === 'done') dependency = data.task && { id: data.task.id, updated_at: data.task.updated_at };
|
|
109
|
+
else if (id.startsWith('select:')) dependency = data.tasks.find((task) => task.id === Number(id.slice(7)));
|
|
110
|
+
else if (!['preview', 'continue', 'clear', 'search', 'add', 'proposal:accept', 'proposal:dismiss'].includes(id) && !id.startsWith('screen:') && !id.startsWith('field:')) {
|
|
111
|
+
dependency = { raw: data.config.raw, ...(['add-field', 'remove-field', 'label', 'format', 'width', 'link'].includes(id) ? { fields: fields(data.config.config, data.task?.agent_notes) } : {}) };
|
|
112
|
+
}
|
|
113
|
+
return createHash('sha256').update(JSON.stringify({ action: id, semantics, state: data.state, dependency })).digest('hex');
|
|
114
|
+
}
|
|
115
|
+
function staleAction(): never {
|
|
116
|
+
throw new McpError(-33003, 'View changed; refresh before acting', { 'native-ui/action': { status: 'not-applied', reason: 'stale-view' } });
|
|
117
|
+
}
|
|
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) {
|
|
102
122
|
const { tasks, task, config, revision } = data;
|
|
103
123
|
const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
|
|
104
124
|
const controls: Control[] = [];
|
|
@@ -110,7 +130,7 @@ function view(data: Snapshot) {
|
|
|
110
130
|
action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
|
|
111
131
|
action('done', 'Complete task', { icon: icons.check });
|
|
112
132
|
} else if (state.proposal) {
|
|
113
|
-
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}` });
|
|
114
134
|
action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
|
|
115
135
|
}
|
|
116
136
|
action('screen:tasks', 'Tasks', { icon: icons.note });
|
|
@@ -144,12 +164,12 @@ function view(data: Snapshot) {
|
|
|
144
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?' });
|
|
145
165
|
if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
|
|
146
166
|
}
|
|
147
|
-
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 }];
|
|
148
168
|
if (task) {
|
|
149
169
|
footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
|
|
150
170
|
footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
|
|
151
171
|
} else if (state.proposal) {
|
|
152
|
-
footer.push({ text: `${icons.
|
|
172
|
+
footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
|
|
153
173
|
footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
|
|
154
174
|
} else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
|
|
155
175
|
const titleParts = footer.length;
|
|
@@ -173,6 +193,7 @@ function view(data: Snapshot) {
|
|
|
173
193
|
body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
|
|
174
194
|
return false;
|
|
175
195
|
});
|
|
196
|
+
if (scoped) for (const control of editable) control.revision = actionRevision(data, control);
|
|
176
197
|
const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
|
|
177
198
|
// JSON escaping and expanded footer formats can exceed the host budget even
|
|
178
199
|
// with bounded row counts. Preserve navigation/filter controls ahead of rows.
|
|
@@ -190,14 +211,15 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [
|
|
|
190
211
|
server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
|
|
191
212
|
if (request.params.uri !== URI) throw new Error('Unknown resource');
|
|
192
213
|
const data = await snapshot(context(request.params._meta).state, extra.signal);
|
|
193
|
-
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
|
|
214
|
+
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
|
|
194
215
|
}));
|
|
195
216
|
server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
196
217
|
const state = context(request.params._meta).state;
|
|
197
218
|
const before = await snapshot(state, extra.signal);
|
|
198
|
-
|
|
219
|
+
const scoped = scopedActions(request.params._meta);
|
|
199
220
|
const { action, value } = request.params;
|
|
200
|
-
|
|
221
|
+
const control = view(before, scoped).controls.find((item) => item.id === action);
|
|
222
|
+
if (!control || (control.revision ?? before.revision) !== request.params.revision) staleAction();
|
|
201
223
|
const next: State = { ...before.state };
|
|
202
224
|
let draft: string | undefined, preview: string | undefined;
|
|
203
225
|
if (action === 'proposal:accept' || action === 'proposal:dismiss') {
|
|
@@ -207,7 +229,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
207
229
|
next.proposal = null;
|
|
208
230
|
if (action === 'proposal:accept') {
|
|
209
231
|
const task: Task = JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
|
|
210
|
-
await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
|
|
232
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
|
|
211
233
|
}
|
|
212
234
|
} else if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
|
|
213
235
|
else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
|
|
@@ -217,14 +239,17 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
217
239
|
else if (action === 'preview') preview = (before.task!.text + '\n\n' + JSON.stringify(before.task!.agent_notes, null, 2)).slice(0, 20_000);
|
|
218
240
|
else if (action.startsWith('select:')) {
|
|
219
241
|
const id = Number(action.slice(7));
|
|
220
|
-
|
|
242
|
+
const candidate = before.tasks.find((task) => task.id === id);
|
|
243
|
+
if (!candidate) staleAction();
|
|
244
|
+
const target: Task = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
|
|
245
|
+
if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag) staleAction();
|
|
221
246
|
if (next.proposal) await consume(next.proposal.id);
|
|
222
|
-
await note(['start', String(id)], extra.signal); next.selectedId = id; next.proposal = null;
|
|
247
|
+
await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal); next.selectedId = id; next.proposal = null;
|
|
223
248
|
} else if (action === 'add') {
|
|
224
249
|
if (next.proposal) await consume(next.proposal.id);
|
|
225
250
|
next.proposal = null;
|
|
226
251
|
const task: Task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
|
|
227
|
-
await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
|
|
252
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
|
|
228
253
|
} else if (action === 'done') {
|
|
229
254
|
await note(['agent', 'done', String(before.task!.id), '--expected-updated-at', before.task!.updated_at], extra.signal);
|
|
230
255
|
next.selectedId = null;
|
|
@@ -250,13 +275,13 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
250
275
|
extra.signal.throwIfAborted();
|
|
251
276
|
saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
|
|
252
277
|
}
|
|
253
|
-
const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
|
|
278
|
+
const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
|
|
254
279
|
await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
|
|
255
280
|
return result;
|
|
256
281
|
}));
|
|
257
282
|
const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
|
|
258
283
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
|
|
259
|
-
{ 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 } },
|
|
260
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 } },
|
|
261
286
|
{ name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
262
287
|
{ name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
@@ -272,7 +297,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
272
297
|
if (!args.text.trim()) throw new Error('Task text must not be blank');
|
|
273
298
|
outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
|
|
274
299
|
if (state.proposal && !await consumed(state.proposal.id)) await consume(state.proposal.id);
|
|
275
|
-
result = { status: 'Suggested; user can accept
|
|
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.' };
|
|
276
301
|
} else if (request.params.name === 'get_current') result = await selected(state, extra.signal);
|
|
277
302
|
else if (request.params.name === 'fields') result = settings().config;
|
|
278
303
|
else if (request.params.name === 'tags') result = JSON.parse(await note(['agent', 'tags'], extra.signal));
|
|
@@ -286,4 +311,36 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
286
311
|
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
|
|
287
312
|
} catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
|
|
288
313
|
}));
|
|
314
|
+
// Every MCP process observes shared storage, including writes by CLI/GTK/other
|
|
315
|
+
// sessions. Stat polling survives missing files and atomic config replacement.
|
|
316
|
+
server.oninitialized = () => {
|
|
317
|
+
server.oninitialized = undefined;
|
|
318
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
319
|
+
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
320
|
+
const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
|
|
321
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
322
|
+
let closed = false;
|
|
323
|
+
const changed = () => {
|
|
324
|
+
if (closed || timer) return;
|
|
325
|
+
timer = setTimeout(() => {
|
|
326
|
+
timer = undefined;
|
|
327
|
+
void server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } }).catch((error) => {
|
|
328
|
+
if (!closed) console.error('Pinote UI update failed:', error);
|
|
329
|
+
});
|
|
330
|
+
}, 100);
|
|
331
|
+
timer.unref();
|
|
332
|
+
};
|
|
333
|
+
const listeners = paths.map((path) => {
|
|
334
|
+
const listener = (current: import('node:fs').Stats, previous: import('node:fs').Stats) => {
|
|
335
|
+
if (current.mtimeMs !== previous.mtimeMs || current.ctimeMs !== previous.ctimeMs || current.size !== previous.size || current.ino !== previous.ino || current.nlink !== previous.nlink) changed();
|
|
336
|
+
};
|
|
337
|
+
watchFile(path, { persistent: false, interval: 1000 }, listener);
|
|
338
|
+
return listener;
|
|
339
|
+
});
|
|
340
|
+
server.onclose = () => {
|
|
341
|
+
closed = true;
|
|
342
|
+
if (timer) clearTimeout(timer);
|
|
343
|
+
paths.forEach((path, index) => unwatchFile(path, listeners[index]));
|
|
344
|
+
};
|
|
345
|
+
};
|
|
289
346
|
await server.connect(new StdioServerTransport());
|