clavarium-binder 0.0.1 → 0.1.0-next.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/LICENSE +21 -0
- package/README.md +48 -1
- package/bin/binder.mjs +96 -0
- package/docs/protocol.md +61 -0
- package/host/binder-host.mjs +127 -0
- package/host/binder-serve.mjs +99 -0
- package/package.json +51 -1
- package/server/binder.mjs +123 -0
- package/server/store.mjs +392 -0
- package/server/templates.mjs +98 -0
- package/ui/binder-ui.css +104 -0
- package/ui/binder-ui.js +256 -0
- package/ui/texts/en.json +11 -0
- package/ui/texts/fr.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Clavarium Docket authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1 +1,48 @@
|
|
|
1
|
-
# Clavarium Binder
|
|
1
|
+
# Clavarium Binder
|
|
2
|
+
|
|
3
|
+
Lists of records, kept together by people and AI agents, each with its id.
|
|
4
|
+
|
|
5
|
+
A binder holds lists: batches of work, development tasks, customers, people,
|
|
6
|
+
a roadmap, anything followed over time. A list starts from a template (its
|
|
7
|
+
states, its fields, the view it opens in) and everything is changed afterwards.
|
|
8
|
+
A record has a title, a state, its fields, short notes, as many things attached
|
|
9
|
+
as wanted (messages of a chat, files, assets, branches, links, other records),
|
|
10
|
+
owners, a due date, and its history.
|
|
11
|
+
|
|
12
|
+
binder your lists
|
|
13
|
+
binder show Batches the records of a list, by state
|
|
14
|
+
binder add Batches "Dark mode"
|
|
15
|
+
binder note 12 "the login page only"
|
|
16
|
+
binder attach 12 branch site:dark-mode
|
|
17
|
+
binder move 12 Doing past a gate, only once a person approved it
|
|
18
|
+
|
|
19
|
+
An MCP server gives the same verbs to an AI agent. A person sees the binder on
|
|
20
|
+
its screen (`ui/`), drawn by Binder in the page of the application that runs it.
|
|
21
|
+
|
|
22
|
+
A sibling of [Clavarium Parley](https://github.com/clavarium/clavarium-parley) and
|
|
23
|
+
[Clavarium Docket](https://github.com/clavarium/clavarium-docket), built the same
|
|
24
|
+
way, universal (nothing in it is specific to Clavarium).
|
|
25
|
+
|
|
26
|
+
## What an agent cannot do
|
|
27
|
+
|
|
28
|
+
- **Rewrite the binder.** It is held by its keeper (the application, or
|
|
29
|
+
`binder serve`), out of the agent's reach. An agent asks through its own
|
|
30
|
+
folder; the keeper answers, and says who asked by that folder.
|
|
31
|
+
- **Approve.** A list may have a gate (Batches: "Approved"): a record reaches
|
|
32
|
+
it, or any state after it, only once a person ticked it. An agent proposes,
|
|
33
|
+
and works on what was approved.
|
|
34
|
+
- **See what it was not given.** Access is given per list: read, or change.
|
|
35
|
+
- **Delete.** It archives; a person deletes, and deleted is deleted.
|
|
36
|
+
|
|
37
|
+
## Without an application
|
|
38
|
+
|
|
39
|
+
binder serve --store ~/binder-data --side ./.binder=Assistant --lang fr
|
|
40
|
+
|
|
41
|
+
keeps a binder and shows its page on `http://127.0.0.1:4180/`. Put the store
|
|
42
|
+
where the agents cannot write.
|
|
43
|
+
|
|
44
|
+
See [docs/protocol.md](docs/protocol.md). No dependency; Node 22 or later.
|
|
45
|
+
|
|
46
|
+
**Status:** first version (0.1), not yet published.
|
|
47
|
+
|
|
48
|
+
License: MIT.
|
package/bin/binder.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* THE COMMAND: Binder's verbs for any agent or script that can run a command.
|
|
3
|
+
* It asks through the same folder as the MCP server (server/binder.mjs), and
|
|
4
|
+
* prints the keeper's answer. `binder serve` IS a keeper: the binder and its
|
|
5
|
+
* page, for use without an application.
|
|
6
|
+
*/
|
|
7
|
+
import { realpathSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { ask, findFolder, FOLDER, VERSION } from '../server/binder.mjs';
|
|
10
|
+
|
|
11
|
+
export const HELP = `binder: the lists you were given, and their records.
|
|
12
|
+
|
|
13
|
+
binder your lists
|
|
14
|
+
binder show LIST [--state S] [--mine] [--archived]
|
|
15
|
+
the records of a list, by state
|
|
16
|
+
binder get ID one record: fields, notes, what is attached, its history
|
|
17
|
+
binder search "text" in every list you can see
|
|
18
|
+
binder add LIST "title" [--state S] [--due DATE] [--owner me] [--note "text"] [--field name=value]
|
|
19
|
+
binder edit ID [--title "t"] [--due DATE|none] [--owner me|ID|none] [--field name=value]
|
|
20
|
+
binder move ID STATE past a gate, only what a person approved
|
|
21
|
+
binder note ID "text" a short note: a decision, a fact, a result
|
|
22
|
+
binder attach ID KIND REF ["title"] KIND: message, file, asset, branch, link, record
|
|
23
|
+
binder detach ID WHAT
|
|
24
|
+
binder archive ID | binder restore ID
|
|
25
|
+
binder serve [--store DIR] [--port N] [--side FOLDER=NAME]
|
|
26
|
+
keep a binder yourself, with its page (no application)
|
|
27
|
+
|
|
28
|
+
ID: a record's number, 12 or #12. DATE: 2026-11-04, or "2026-11-04 09:00".
|
|
29
|
+
You never approve: a person ticks what may go on. Talking with them stays in the chat.
|
|
30
|
+
The folder used is --dir FOLDER, else $BINDER_DIR, else the nearest ${FOLDER} folder from here up.
|
|
31
|
+
binder ${VERSION}`;
|
|
32
|
+
|
|
33
|
+
const VERBS = ['lists', 'show', 'get', 'search', 'add', 'edit', 'move', 'note', 'attach', 'detach', 'archive', 'restore'];
|
|
34
|
+
|
|
35
|
+
/* A command line, read: [op, args] or a refusal. */
|
|
36
|
+
export function read(argv) {
|
|
37
|
+
const args = [...argv];
|
|
38
|
+
const flags = {}; const fields = {}; const words = [];
|
|
39
|
+
for (let i = 0; i < args.length; i++) {
|
|
40
|
+
const a = args[i];
|
|
41
|
+
if (a === '--mine' || a === '--archived') { flags[a.slice(2)] = true; continue; }
|
|
42
|
+
if (a === '--field') { const v = args[++i] ?? ''; const at = v.indexOf('='); if (at < 1) return { refused: '--field takes name=value' }; fields[v.slice(0, at)] = v.slice(at + 1); continue; }
|
|
43
|
+
if (a.startsWith('--')) { if (args[i + 1] === undefined) return { refused: `${a} needs a value` }; flags[a.slice(2)] = args[++i]; continue; }
|
|
44
|
+
words.push(a);
|
|
45
|
+
}
|
|
46
|
+
const [verb = 'lists', ...rest] = words;
|
|
47
|
+
if (!VERBS.includes(verb)) return { refused: `Unknown: ${verb}. Run "binder --help".` };
|
|
48
|
+
const owners = flags.owner === undefined ? undefined : flags.owner === 'none' ? [] : [flags.owner];
|
|
49
|
+
const due = flags.due === 'none' ? '' : flags.due;
|
|
50
|
+
const has = Object.keys(fields).length ? { fields } : {};
|
|
51
|
+
switch (verb) {
|
|
52
|
+
case 'lists': return ['lists', {}];
|
|
53
|
+
case 'show': return rest[0] ? ['show', { list: rest[0], state: flags.state, mine: !!flags.mine, archived: !!flags.archived }] : { refused: 'say which list: binder show LIST' };
|
|
54
|
+
case 'get': return rest[0] ? ['get', { id: rest[0] }] : { refused: 'say which record: binder get ID' };
|
|
55
|
+
case 'search': return rest.length ? ['search', { text: rest.join(' ') }] : { refused: 'say what to look for' };
|
|
56
|
+
case 'add': return rest.length >= 2 ? ['add', { list: rest[0], title: rest.slice(1).join(' '), state: flags.state, due, owners, note: flags.note, ...has }] : { refused: 'binder add LIST "title"' };
|
|
57
|
+
case 'edit': return rest[0] ? ['edit', { id: rest[0], title: flags.title, due, owners, ...has }] : { refused: 'say which record: binder edit ID' };
|
|
58
|
+
case 'move': return rest.length === 2 ? ['move', { id: rest[0], state: rest[1] }] : { refused: 'binder move ID STATE' };
|
|
59
|
+
case 'note': return rest.length >= 2 ? ['note', { id: rest[0], text: rest.slice(1).join(' ') }] : { refused: 'binder note ID "text"' };
|
|
60
|
+
case 'attach': return rest.length >= 3 ? ['attach', { id: rest[0], kind: rest[1], ref: rest[2], title: rest.slice(3).join(' ') }] : { refused: 'binder attach ID KIND REF ["title"]' };
|
|
61
|
+
case 'detach': return rest.length === 2 ? ['detach', { id: rest[0], what: rest[1] }] : { refused: 'binder detach ID WHAT' };
|
|
62
|
+
case 'archive': case 'restore': return rest[0] ? ['archive', { id: rest[0], restore: verb === 'restore' }] : { refused: `binder ${verb} ID` };
|
|
63
|
+
default: return { refused: 'not understood' };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Runs one command line. Resolves with { code, out, err }: nothing is printed here. */
|
|
68
|
+
export async function run(argv, { env = process.env, cwd = process.cwd(), wait } = {}) {
|
|
69
|
+
const args = [...argv];
|
|
70
|
+
let dir;
|
|
71
|
+
const at = args.indexOf('--dir');
|
|
72
|
+
if (at >= 0) { dir = args[at + 1]; if (!dir) return { code: 64, out: '', err: '--dir needs a folder.\n' }; args.splice(at, 2); }
|
|
73
|
+
if (['--help', '-h', 'help'].includes(args[0])) return { code: 0, out: `${HELP}\n`, err: '' };
|
|
74
|
+
if (args[0] === '--version') return { code: 0, out: `${VERSION}\n`, err: '' };
|
|
75
|
+
const what = read(args);
|
|
76
|
+
if (what.refused) return { code: 64, out: '', err: `${what.refused}\n` };
|
|
77
|
+
const folder = findFolder({ dir, env, from: cwd });
|
|
78
|
+
if (!folder) return { code: 2, out: '', err: `No ${FOLDER} folder here or above. Run it from your work folder, or give --dir FOLDER.\n` };
|
|
79
|
+
const r = await ask(folder, what[0], what[1], wait ? { wait } : {});
|
|
80
|
+
if (r.ok) return { code: 0, out: `${r.text}\n`, err: '' };
|
|
81
|
+
return { code: r.nokeeper ? 3 : 1, out: '', err: `Not done: ${r.text}\n` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const main = (() => { try { return realpathSync(process.argv[1] ?? '') === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
|
|
85
|
+
if (main) {
|
|
86
|
+
const argv = process.argv.slice(2);
|
|
87
|
+
if (argv[0] === 'serve') {
|
|
88
|
+
const { serveBinder } = await import('../host/binder-serve.mjs');
|
|
89
|
+
await serveBinder(argv.slice(1));
|
|
90
|
+
} else {
|
|
91
|
+
const r = await run(argv);
|
|
92
|
+
if (r.out) process.stdout.write(r.out);
|
|
93
|
+
if (r.err) process.stderr.write(r.err);
|
|
94
|
+
process.exitCode = r.code;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# The protocol
|
|
2
|
+
|
|
3
|
+
Binder is four small things: a command and an MCP server for agents, a keeper
|
|
4
|
+
that holds the binder, and a screen for people.
|
|
5
|
+
|
|
6
|
+
## The store: the keeper's alone
|
|
7
|
+
|
|
8
|
+
| File | What it holds |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `state.json` | every list and record as it is now, rewritten whole (by a rename) after each change |
|
|
11
|
+
| `log.jsonl` | every change, one per line: `{at, by, op, list?, record?, change?}` |
|
|
12
|
+
|
|
13
|
+
`by` is who did it: `{id, kind, name}`, kind `person` or `agent`. A record
|
|
14
|
+
deleted is deleted: its lines in the log are emptied down to who and when, and
|
|
15
|
+
one `delete` line says by whom.
|
|
16
|
+
|
|
17
|
+
## An agent's folder
|
|
18
|
+
|
|
19
|
+
| File | Written by | What it holds |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `asks.jsonl` | the agent | `{id, at, op, args}`, one per ask |
|
|
22
|
+
| `answers.jsonl` | the keeper | `{ask, at, ok, text}`: the answer to an ask, by its id |
|
|
23
|
+
|
|
24
|
+
The folder is `--dir`, else `$BINDER_DIR`, else the nearest `.binder` from
|
|
25
|
+
where the command stands. The keeper knows who asks by the folder: the
|
|
26
|
+
application gives each folder its `who`. An ask waits for its answer up to 20
|
|
27
|
+
seconds; an ask older than a minute is answered "too late" and never done.
|
|
28
|
+
Exit codes of the command: 0 done, 1 refused, 2 no folder, 3 nobody keeps the
|
|
29
|
+
binder, 64 not understood.
|
|
30
|
+
|
|
31
|
+
## The verbs
|
|
32
|
+
|
|
33
|
+
| Command | MCP tool | Who |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `binder` | `lists()` | anyone: the lists given to them |
|
|
36
|
+
| `binder show LIST [--state S] [--mine] [--archived]` | `show` | read |
|
|
37
|
+
| `binder get ID` | `get` | read |
|
|
38
|
+
| `binder search TEXT` | `search` | read |
|
|
39
|
+
| `binder add LIST TITLE [...]` | `add` | change |
|
|
40
|
+
| `binder edit ID [...]` | `edit` | change |
|
|
41
|
+
| `binder move ID STATE` | `move` | change; past a gate, only an approved record |
|
|
42
|
+
| `binder note ID TEXT` | `note` | change |
|
|
43
|
+
| `binder attach ID KIND REF [TITLE]` | `attach` | change |
|
|
44
|
+
| `binder detach ID WHAT` | `detach` | change |
|
|
45
|
+
| `binder archive ID`, `binder restore ID` | `archive` | change |
|
|
46
|
+
|
|
47
|
+
A person's alone (from the screen): `approve`, `delete`, `create-list`,
|
|
48
|
+
`edit-list`, `grant`, `archive-list`, `delete-list`; a note is changed by
|
|
49
|
+
whoever wrote it (`edit-note`).
|
|
50
|
+
|
|
51
|
+
## The application's side
|
|
52
|
+
|
|
53
|
+
import { keeper } from 'clavarium-binder/host';
|
|
54
|
+
const k = keeper(storeDir, { sides: () => [{ dir, who }] });
|
|
55
|
+
setInterval(() => k.pump(), 1000); // answer the agents
|
|
56
|
+
k.act(op, args, person); // a person's change: { ok, text, data }
|
|
57
|
+
k.due(); // records whose due time came: tell their owners
|
|
58
|
+
k.store.onChange((line) => ...); // every change, as the log says it
|
|
59
|
+
|
|
60
|
+
And the screen: `mount(element, { call, words, lang, people, picture, openAttached })`
|
|
61
|
+
from `clavarium-binder/ui`, with `ui/binder-ui.css` and `ui/texts/*.json`.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/* THE KEEPER: the side that holds the binder and answers. An application that
|
|
2
|
+
* runs Binder (or `binder serve`) keeps one store, and reads the asks of each
|
|
3
|
+
* agent's folder; a person's changes come straight from the application's page.
|
|
4
|
+
*
|
|
5
|
+
* const k = keeper(storeDir, { sides: () => [{ dir, who }] });
|
|
6
|
+
* k.pump(); answer what was asked since (every second or so)
|
|
7
|
+
* k.act(op, args, who) a person's change, from the page: { ok, text, data }
|
|
8
|
+
* k.due() records whose due time came: tell their owners
|
|
9
|
+
* k.store.onChange(fn) every change, as the log says it
|
|
10
|
+
*
|
|
11
|
+
* WHO ASKS IS SAID BY THE FOLDER. The application gives each folder its who
|
|
12
|
+
* ({ id, kind: "agent", name }); what an ask says of itself is never believed.
|
|
13
|
+
*
|
|
14
|
+
* An ask too old is answered "too late", never done: the command that sent it
|
|
15
|
+
* gave up waiting and said nothing was changed.
|
|
16
|
+
*/
|
|
17
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { openStore, Refused, oneLine } from '../server/store.mjs';
|
|
20
|
+
import { lines } from '../server/binder.mjs';
|
|
21
|
+
import { TEMPLATES } from '../server/templates.mjs';
|
|
22
|
+
|
|
23
|
+
export { openStore, Refused, TEMPLATES };
|
|
24
|
+
/* Older than this, an ask is not done: its command stopped waiting (WAIT_MS) a while ago. */
|
|
25
|
+
export const TOO_LATE_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
const day = (iso) => String(iso ?? '').slice(0, 10);
|
|
28
|
+
const owners = (r) => (r.owners.length ? ` @${r.owners.join(' @')}` : '');
|
|
29
|
+
const due = (r) => (r.due ? ` (due ${r.due})` : '');
|
|
30
|
+
|
|
31
|
+
/* ---- what an agent reads: plain lines ---- */
|
|
32
|
+
export function say(op, data, list) {
|
|
33
|
+
switch (op) {
|
|
34
|
+
case 'lists': return data.length === 0 ? 'You were given no list.'
|
|
35
|
+
: ['Your lists:', ...data.map((l) => ` ${l.title} [${l.id}]: ${l.count} record${l.count === 1 ? '' : 's'}, ${l.access === 'write' ? 'you may change it' : 'read only'}`),
|
|
36
|
+
'See one: binder show LIST'].join('\n');
|
|
37
|
+
case 'show': {
|
|
38
|
+
const { list: l, records } = data;
|
|
39
|
+
const out = [`${l.title} [${l.id}]${l.access === 'write' ? '' : ' (read only)'}: ${records.length} record${records.length === 1 ? '' : 's'}`];
|
|
40
|
+
for (const st of l.states) {
|
|
41
|
+
const here = records.filter((r) => r.state === st.id);
|
|
42
|
+
if (!here.length) continue;
|
|
43
|
+
out.push(`${st.name}${st.gate ? ' (a person approves)' : ''}:`);
|
|
44
|
+
for (const r of here) out.push(` #${r.id} ${r.title}${owners(r)}${due(r)}${r.attached.length ? ` [${r.attached.length} attached]` : ''}`);
|
|
45
|
+
}
|
|
46
|
+
if (l.states.some((x) => x.gate)) out.push('Past a gate, only what a person approved.');
|
|
47
|
+
return out.join('\n');
|
|
48
|
+
}
|
|
49
|
+
case 'get': {
|
|
50
|
+
const { record: r, list: l, history } = data;
|
|
51
|
+
const st = l.states.find((x) => x.id === r.state);
|
|
52
|
+
const out = [`#${r.id} ${r.title}`, ` list: ${l.title}; state: ${st?.name ?? r.state}${r.approved ? ' (approved)' : ''}${r.archived ? '; archived' : ''}${owners(r) ? `; owners:${owners(r)}` : ''}${due(r)}`];
|
|
53
|
+
for (const f of l.fields) if (r.fields[f.id] !== undefined) out.push(` ${f.name}: ${r.fields[f.id]}`);
|
|
54
|
+
if (r.notes.length) { out.push('Notes:'); for (const n of r.notes) out.push(` ${day(n.at)} ${n.by.name}: ${n.text.replace(/\n/g, '\n ')}${n.edited ? ' (edited)' : ''}`); }
|
|
55
|
+
if (r.attached.length) { out.push('Attached:'); for (const a of r.attached) out.push(` ${a.kind} ${a.kind === 'record' ? `#${a.ref}` : a.ref}${a.title ? ` "${a.title}"` : ''}`); }
|
|
56
|
+
out.push(`History: ${history.length} change${history.length === 1 ? '' : 's'}, the last ${history.length ? `${day(history.at(-1).at)} by ${history.at(-1).by.name}` : 'none'}.`);
|
|
57
|
+
return out.join('\n');
|
|
58
|
+
}
|
|
59
|
+
case 'search': return data.length === 0 ? 'Nothing found.' : data.map((r) => `#${r.id} ${r.title} (${list(r.list)})`).join('\n');
|
|
60
|
+
case 'add': return `Added #${data.id}: ${data.title}.`;
|
|
61
|
+
case 'edit': return `Changed #${data.id}.`;
|
|
62
|
+
case 'move': return `#${data.id} is now ${data.stateName}.`;
|
|
63
|
+
case 'note': return 'Noted.';
|
|
64
|
+
case 'attach': return 'Attached.';
|
|
65
|
+
case 'detach': return 'Taken off.';
|
|
66
|
+
case 'archive': return data.archived ? `Archived #${data.id}.` : `Restored #${data.id}.`;
|
|
67
|
+
default: return 'Done.';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function keeper(dir, { sides = () => [], now = () => Date.now() } = {}) {
|
|
72
|
+
const store = openStore(dir, { now });
|
|
73
|
+
const titleOf = (id) => store.lists({ id: 'keeper', kind: 'person' }).find((l) => l.id === id)?.title ?? id;
|
|
74
|
+
/* ONE ASK, as `who`: { ok, text, data }. An agent's "me" is its own id. */
|
|
75
|
+
const perform = (op, a = {}, who) => {
|
|
76
|
+
try {
|
|
77
|
+
const me = (list) => (Array.isArray(list) ? list.map((x) => (x === 'me' ? who.id : x)) : list);
|
|
78
|
+
let data;
|
|
79
|
+
switch (op) {
|
|
80
|
+
case 'lists': data = store.lists(who); break;
|
|
81
|
+
case 'show': data = store.list(a.list, who, { state: a.state, mine: a.mine, archived: a.archived, text: a.text }); break;
|
|
82
|
+
case 'get': data = store.record(a.id, who); break;
|
|
83
|
+
case 'search': data = store.search(a.text, who); break;
|
|
84
|
+
case 'add': data = store.add(a.list, { title: a.title, state: a.state, fields: a.fields, owners: me(a.owners), due: a.due, note: a.note }, who); break;
|
|
85
|
+
case 'edit': data = store.edit(a.id, { title: a.title, fields: a.fields, owners: me(a.owners), due: a.due }, who); break;
|
|
86
|
+
case 'move': { const r = store.move(a.id, a.state, who); data = { ...r, stateName: store.record(r.id, who).list.states.find((x) => x.id === r.state)?.name }; break; }
|
|
87
|
+
case 'note': data = store.note(a.id, a.text, who); break;
|
|
88
|
+
case 'attach': data = store.attach(a.id, { kind: a.kind, ref: a.ref, title: a.title }, who); break;
|
|
89
|
+
case 'detach': data = store.detach(a.id, a.what, who); break;
|
|
90
|
+
case 'archive': data = store.archive(a.id, !a.restore, who); break;
|
|
91
|
+
/* A person's, from the page. */
|
|
92
|
+
case 'approve': data = store.approve(a.id, a.yes !== false, who); break;
|
|
93
|
+
case 'delete': data = store.delete(a.id, who); break;
|
|
94
|
+
case 'edit-note': data = store.editNote(a.id, a.note, a.text, who); break;
|
|
95
|
+
case 'create-list': data = store.createList({ template: a.template, title: a.title, lang: a.lang }, who); break;
|
|
96
|
+
case 'edit-list': data = store.editList(a.list, { title: a.title, view: a.view, states: a.states, fields: a.fields }, who); break;
|
|
97
|
+
case 'grant': data = store.grant(a.list, a.to, a.access ?? null, who); break;
|
|
98
|
+
case 'archive-list': data = store.archiveList(a.list, a.archived !== false, who); break;
|
|
99
|
+
case 'delete-list': data = store.deleteList(a.list, who); break;
|
|
100
|
+
case 'templates': data = Object.fromEntries(Object.entries(TEMPLATES).map(([k, t]) => [k, t.name])); break;
|
|
101
|
+
default: return { ok: false, text: `there is no ${oneLine(op, 40)}` };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, text: say(op, data, titleOf), data };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof Refused) return { ok: false, text: error.message };
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/* What each folder asked and was not answered yet. Answered asks are known by the answers file. */
|
|
110
|
+
const pump = () => {
|
|
111
|
+
let n = 0;
|
|
112
|
+
for (const side of sides()) {
|
|
113
|
+
const answered = new Set(lines(join(side.dir, 'answers.jsonl')).map((a) => a.ask));
|
|
114
|
+
for (const q of lines(join(side.dir, 'asks.jsonl'))) {
|
|
115
|
+
if (typeof q.id !== 'string' || answered.has(q.id)) continue;
|
|
116
|
+
const late = !(now() - Date.parse(q.at) < TOO_LATE_MS);
|
|
117
|
+
const answer = late ? { ok: false, text: 'too late: this ask waited too long and was not done' }
|
|
118
|
+
: perform(oneLine(q.op, 40), q.args && typeof q.args === 'object' ? q.args : {}, side.who);
|
|
119
|
+
mkdirSync(side.dir, { recursive: true });
|
|
120
|
+
appendFileSync(join(side.dir, 'answers.jsonl'), `${JSON.stringify({ ask: q.id, at: new Date(now()).toISOString(), ok: answer.ok, text: answer.text })}\n`);
|
|
121
|
+
answered.add(q.id); n++;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return n;
|
|
125
|
+
};
|
|
126
|
+
return { store, pump, act: perform, due: () => store.due() };
|
|
127
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/* BINDER WITHOUT AN APPLICATION: `binder serve` keeps a binder itself, and
|
|
2
|
+
* shows its page to the person on this machine.
|
|
3
|
+
*
|
|
4
|
+
* binder serve [--store DIR] [--port N] [--side FOLDER=NAME]... [--lang fr]
|
|
5
|
+
*
|
|
6
|
+
* The store is DIR (default: $BINDER_STORE, else ~/.local/share/clavarium-binder):
|
|
7
|
+
* put it where the agents cannot write -- another user's, or outside what they
|
|
8
|
+
* are given -- since whoever can write the store can rewrite the binder.
|
|
9
|
+
* Each --side is an agent's folder (its .binder) and the name it is shown by;
|
|
10
|
+
* without one, the .binder of the folder it is started from, as "Agent".
|
|
11
|
+
*
|
|
12
|
+
* The page listens on 127.0.0.1 only: who reaches it is the person. Nothing here
|
|
13
|
+
* is meant to face a network; an application that does gives its own door.
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join, resolve, dirname } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { keeper } from './binder-host.mjs';
|
|
21
|
+
import { FOLDER } from '../server/binder.mjs';
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const UI = resolve(HERE, '../ui');
|
|
25
|
+
const PERSON = { id: 'person:you', kind: 'person', name: 'You' };
|
|
26
|
+
const BODY_MAX = 256 * 1024;
|
|
27
|
+
|
|
28
|
+
const PAGE = (lang) => `<!doctype html><html lang="${lang}"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
29
|
+
<title>Binder</title><link rel="stylesheet" href="/ui/binder-ui.css">
|
|
30
|
+
<style>html,body{margin:0;background:#0f1115}@media (prefers-color-scheme:light){html,body{background:#fff}}body{font:15px/1.4 system-ui,sans-serif;padding:16px;max-width:1200px;margin:auto}</style>
|
|
31
|
+
<div id="binder"></div>
|
|
32
|
+
<script type="module">
|
|
33
|
+
import { mount } from '/ui/binder-ui.js';
|
|
34
|
+
const words = await (await fetch('/ui/texts/${lang}.json')).json();
|
|
35
|
+
const people = await (await fetch('/api/people')).json();
|
|
36
|
+
const call = async (op, args) => (await fetch('/api', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ op, args }) })).json();
|
|
37
|
+
const b = mount(document.getElementById('binder'), { call, words, lang: '${lang}', people: () => people });
|
|
38
|
+
new EventSource('/api/changes').onmessage = () => b.refresh();
|
|
39
|
+
</script></html>`;
|
|
40
|
+
|
|
41
|
+
export function options(argv, { env = process.env, cwd = process.cwd() } = {}) {
|
|
42
|
+
const o = { store: env.BINDER_STORE || join(homedir(), '.local', 'share', 'clavarium-binder'), port: 4180, sides: [], lang: 'en' };
|
|
43
|
+
for (let i = 0; i < argv.length; i++) {
|
|
44
|
+
const a = argv[i]; const v = argv[i + 1];
|
|
45
|
+
if (a === '--store' && v) { o.store = resolve(cwd, v); i++; }
|
|
46
|
+
else if (a === '--port' && v) { o.port = Number(v); i++; }
|
|
47
|
+
else if (a === '--lang' && v) { o.lang = v === 'fr' ? 'fr' : 'en'; i++; }
|
|
48
|
+
else if (a === '--side' && v) {
|
|
49
|
+
const at = v.lastIndexOf('='); const dir = resolve(cwd, at > 0 ? v.slice(0, at) : v); const name = at > 0 ? v.slice(at + 1) : 'Agent';
|
|
50
|
+
o.sides.push({ dir, who: { id: `agent:${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, kind: 'agent', name } }); i++;
|
|
51
|
+
} else throw new Error(`not understood: ${a}`);
|
|
52
|
+
}
|
|
53
|
+
if (!o.sides.length) o.sides.push({ dir: join(cwd, FOLDER), who: { id: 'agent:agent', kind: 'agent', name: 'Agent' } });
|
|
54
|
+
if (!Number.isInteger(o.port) || o.port < 1 || o.port > 65535) throw new Error('--port takes a number');
|
|
55
|
+
return o;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function start(o, { quiet = false } = {}) {
|
|
59
|
+
for (const s of o.sides) mkdirSync(s.dir, { recursive: true });
|
|
60
|
+
const k = keeper(o.store, { sides: () => o.sides });
|
|
61
|
+
const watchers = new Set();
|
|
62
|
+
k.store.onChange(() => { for (const res of watchers) res.write('data: change\n\n'); });
|
|
63
|
+
const pump = setInterval(() => { try { k.pump(); } catch (error) { if (!quiet) process.stderr.write(`binder: ${error.message}\n`); } }, 500);
|
|
64
|
+
const files = { '/ui/binder-ui.js': 'text/javascript', '/ui/binder-ui.css': 'text/css', '/ui/texts/en.json': 'application/json', '/ui/texts/fr.json': 'application/json' };
|
|
65
|
+
const server = createServer((req, res) => {
|
|
66
|
+
const url = new URL(req.url, 'http://local');
|
|
67
|
+
const send = (code, type, body) => { res.writeHead(code, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(body); };
|
|
68
|
+
if (req.method === 'GET' && url.pathname === '/') return send(200, 'text/html', PAGE(o.lang));
|
|
69
|
+
if (req.method === 'GET' && files[url.pathname]) return send(200, files[url.pathname], readFileSync(join(UI, url.pathname.slice('/ui/'.length))));
|
|
70
|
+
if (req.method === 'GET' && url.pathname === '/api/people') return send(200, 'application/json', JSON.stringify([PERSON, ...o.sides.map((s) => s.who)]));
|
|
71
|
+
if (req.method === 'GET' && url.pathname === '/api/changes') {
|
|
72
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store' }); res.write(': on\n\n');
|
|
73
|
+
watchers.add(res); req.on('close', () => watchers.delete(res)); return undefined;
|
|
74
|
+
}
|
|
75
|
+
if (req.method === 'POST' && url.pathname === '/api') {
|
|
76
|
+
/* A page of another site cannot post here: the type it would need is not one a form sends. */
|
|
77
|
+
if (!String(req.headers['content-type'] ?? '').startsWith('application/json')) return send(415, 'text/plain', 'json only');
|
|
78
|
+
let body = '';
|
|
79
|
+
req.on('data', (c) => { body += c; if (body.length > BODY_MAX) req.destroy(); });
|
|
80
|
+
req.on('end', () => {
|
|
81
|
+
let q; try { q = JSON.parse(body); } catch { return send(400, 'application/json', JSON.stringify({ ok: false, text: 'not understood' })); }
|
|
82
|
+
const r = k.act(String(q?.op ?? ''), q?.args && typeof q.args === 'object' ? q.args : {}, PERSON);
|
|
83
|
+
return send(200, 'application/json', JSON.stringify(r));
|
|
84
|
+
});
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
return send(404, 'text/plain', 'not here');
|
|
88
|
+
});
|
|
89
|
+
return new Promise((ok) => server.listen(o.port, '127.0.0.1', () => {
|
|
90
|
+
if (!quiet) process.stdout.write(`Binder kept in ${o.store}\nPage: http://127.0.0.1:${server.address().port}/\nAgents: ${o.sides.map((s) => `${s.who.name} (${s.dir})`).join(', ')}\n`);
|
|
91
|
+
ok({ server, keeper: k, port: server.address().port, close: () => { clearInterval(pump); for (const r of watchers) r.end(); server.close(); } });
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function serveBinder(argv) {
|
|
96
|
+
let o;
|
|
97
|
+
try { o = options(argv); } catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 64; return; }
|
|
98
|
+
await start(o);
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1,51 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "clavarium-binder",
|
|
3
|
+
"version": "0.1.0-next.1",
|
|
4
|
+
"description": "Clavarium Binder: lists of records kept together by people and AI agents, each with its id (a command and an MCP server for agents, a keeper and a screen for the application). No dependency.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/clavarium/clavarium-binder#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/clavarium/clavarium-binder.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"binder": "bin/binder.mjs",
|
|
14
|
+
"binder-mcp": "server/binder.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"server/",
|
|
19
|
+
"host/",
|
|
20
|
+
"ui/",
|
|
21
|
+
"docs/",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"check": "npm run check:hooks && npm run check:shapes && npm run check:names && npm run check:shell && npm test",
|
|
34
|
+
"check:shapes": "node tools/check-shapes.mjs",
|
|
35
|
+
"check:names": "node tools/check-ai-names.mjs",
|
|
36
|
+
"check:shell": "node tools/check-shell.mjs",
|
|
37
|
+
"check:hooks": "node tools/check-hooks.mjs",
|
|
38
|
+
"hooks": "git config core.hooksPath hooks",
|
|
39
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
40
|
+
},
|
|
41
|
+
"exports": {
|
|
42
|
+
"./host": "./host/binder-host.mjs",
|
|
43
|
+
"./serve": "./host/binder-serve.mjs",
|
|
44
|
+
"./server": "./server/binder.mjs",
|
|
45
|
+
"./store": "./server/store.mjs",
|
|
46
|
+
"./templates": "./server/templates.mjs",
|
|
47
|
+
"./ui": "./ui/binder-ui.js",
|
|
48
|
+
"./ui.css": "./ui/binder-ui.css",
|
|
49
|
+
"./ui/texts/*": "./ui/texts/*"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* THE ASKING SIDE OF BINDER: how an agent, or a script, reads and changes the
|
|
3
|
+
* lists it was given. One file, no dependency.
|
|
4
|
+
*
|
|
5
|
+
* a command `binder`, `binder show LIST`, `binder add LIST "title"`...
|
|
6
|
+
* (bin/binder.mjs imports this file)
|
|
7
|
+
* MCP `node binder.mjs [--dir FOLDER]`: the same verbs as tools,
|
|
8
|
+
* over standard input and output (JSON-RPC 2.0, one message per line)
|
|
9
|
+
*
|
|
10
|
+
* IT ASKS, IT NEVER WRITES THE BINDER. The binder is held by its keeper (binder
|
|
11
|
+
* serve, or the application that runs Binder), out of the agent's reach: what
|
|
12
|
+
* an agent could write, it could rewrite. Here, the agent's own folder:
|
|
13
|
+
*
|
|
14
|
+
* asks.jsonl the agent's: what it asks, one line each
|
|
15
|
+
* answers.jsonl the keeper's: the answer to each ask, by its id
|
|
16
|
+
*
|
|
17
|
+
* The keeper says who asks by the folder the ask came from: an agent cannot
|
|
18
|
+
* speak as another one, nor as a person. See docs/protocol.md.
|
|
19
|
+
*/
|
|
20
|
+
import { appendFileSync, readFileSync, mkdirSync, statSync, lstatSync, realpathSync } from 'node:fs';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { createInterface } from 'node:readline';
|
|
25
|
+
|
|
26
|
+
export const VERSION = '0.1.0-next.1';
|
|
27
|
+
export const FOLDER = '.binder';
|
|
28
|
+
/* How long an ask waits for its answer. The keeper answers within a second or two. */
|
|
29
|
+
export const WAIT_MS = 20_000;
|
|
30
|
+
export const NO_KEEPER = 'No answer: nobody keeps this binder right now (its application, or "binder serve", is not running). Nothing was changed.';
|
|
31
|
+
|
|
32
|
+
const parsed = (line) => { try { const j = JSON.parse(line); return j && typeof j === 'object' && !Array.isArray(j) ? j : null; } catch { return null; } };
|
|
33
|
+
export function lines(file) {
|
|
34
|
+
try { if (lstatSync(file).isSymbolicLink()) return []; return readFileSync(file, 'utf8').split('\n').filter(Boolean).map(parsed).filter(Boolean); } catch { return []; }
|
|
35
|
+
}
|
|
36
|
+
const isFolder = (path) => { try { return statSync(path).isDirectory(); } catch { return false; } };
|
|
37
|
+
|
|
38
|
+
/* The folder: `dir`, else $BINDER_DIR, else the nearest .binder from `from` up. */
|
|
39
|
+
export function findFolder({ dir, env = process.env, from = process.cwd() } = {}) {
|
|
40
|
+
if (dir) return resolve(from, dir);
|
|
41
|
+
if (env.BINDER_DIR) return resolve(from, env.BINDER_DIR);
|
|
42
|
+
for (let at = resolve(from); ; at = dirname(at)) {
|
|
43
|
+
if (isFolder(join(at, FOLDER))) return join(at, FOLDER);
|
|
44
|
+
if (dirname(at) === at) return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* One ask, and its answer: { ok, text, data? }. */
|
|
49
|
+
export async function ask(dir, op, args = {}, { wait = WAIT_MS, every = 150 } = {}) {
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
const id = randomUUID();
|
|
52
|
+
appendFileSync(join(dir, 'asks.jsonl'), `${JSON.stringify({ id, at: new Date().toISOString(), op, args })}\n`);
|
|
53
|
+
const until = Date.now() + wait;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const answer = lines(join(dir, 'answers.jsonl')).find((a) => a.ask === id);
|
|
56
|
+
if (answer) return { ok: answer.ok === true, text: String(answer.text ?? ''), ...(answer.data !== undefined ? { data: answer.data } : {}) };
|
|
57
|
+
if (Date.now() >= until) return { ok: false, text: NO_KEEPER, nokeeper: true };
|
|
58
|
+
await new Promise((r) => setTimeout(r, every));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ---- MCP ---- */
|
|
63
|
+
const S = (description) => ({ type: 'string', description });
|
|
64
|
+
export const TOOLS = [
|
|
65
|
+
{ name: 'lists', description: 'The lists you were given, with how many records each holds and whether you may change them.', inputSchema: { type: 'object', properties: {} } },
|
|
66
|
+
{ name: 'show', description: 'The records of a list, by state. Filter by state, or only yours.', inputSchema: { type: 'object', properties: { list: S('its name or id'), state: S('a state of the list'), mine: { type: 'boolean' }, archived: { type: 'boolean' } }, required: ['list'] } },
|
|
67
|
+
{ name: 'get', description: 'One record whole: its fields, notes, what is attached, its history.', inputSchema: { type: 'object', properties: { id: S('the record number, as #12 or 12') }, required: ['id'] } },
|
|
68
|
+
{ name: 'search', description: 'Records whose title, notes or fields hold a text, in every list you can see.', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } },
|
|
69
|
+
{ name: 'add', description: 'Add a record to a list (in its first state unless you say). You cannot add it approved: a person ticks it.', inputSchema: { type: 'object', properties: { list: S('its name or id'), title: { type: 'string' }, state: { type: 'string' }, due: S('2026-11-04 or 2026-11-04 09:00'), owner: S('"me", or an id'), note: { type: 'string' }, fields: { type: 'object', description: '{ field name: value }' } }, required: ['list', 'title'] } },
|
|
70
|
+
{ name: 'edit', description: 'Change a record: title, fields, owners, due date (empty to remove it).', inputSchema: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, due: { type: 'string' }, owner: S('"me", or an id; empty for nobody'), fields: { type: 'object' } }, required: ['id'] } },
|
|
71
|
+
{ name: 'move', description: 'Put a record in another state of its list. Past a gate (an approval), only once a person approved it.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, state: { type: 'string' } }, required: ['id', 'state'] } },
|
|
72
|
+
{ name: 'note', description: 'Add a short note to a record: a decision, a fact, a result. Talking with the person stays in the chat.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' } }, required: ['id', 'text'] } },
|
|
73
|
+
{ name: 'attach', description: 'Attach something to a record: a message of the chat, a file, an asset, a branch, a link, another record.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, kind: { type: 'string', enum: ['message', 'file', 'asset', 'branch', 'link', 'record'] }, ref: S('what it is: a path, a URL, repository:branch, a message id, a record number'), title: { type: 'string' } }, required: ['id', 'kind', 'ref'] } },
|
|
74
|
+
{ name: 'detach', description: 'Take back something attached to a record.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, what: S('what get shows of it: its ref or its id') }, required: ['id', 'what'] } },
|
|
75
|
+
{ name: 'archive', description: 'Archive a record (it leaves the list; found again with archived), or restore it.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, restore: { type: 'boolean' } }, required: ['id'] } },
|
|
76
|
+
];
|
|
77
|
+
/* A tool's arguments as the keeper reads them. */
|
|
78
|
+
export function asked(name, a = {}) {
|
|
79
|
+
const owners = a.owner === undefined ? undefined : a.owner === '' ? [] : [a.owner];
|
|
80
|
+
switch (name) {
|
|
81
|
+
case 'lists': return ['lists', {}];
|
|
82
|
+
case 'show': return ['show', { list: a.list, state: a.state, mine: !!a.mine, archived: !!a.archived }];
|
|
83
|
+
case 'get': return ['get', { id: a.id }];
|
|
84
|
+
case 'search': return ['search', { text: a.text }];
|
|
85
|
+
case 'add': return ['add', { list: a.list, title: a.title, state: a.state, due: a.due, owners, note: a.note, fields: a.fields }];
|
|
86
|
+
case 'edit': return ['edit', { id: a.id, title: a.title, due: a.due, owners, fields: a.fields }];
|
|
87
|
+
case 'move': return ['move', { id: a.id, state: a.state }];
|
|
88
|
+
case 'note': return ['note', { id: a.id, text: a.text }];
|
|
89
|
+
case 'attach': return ['attach', { id: a.id, kind: a.kind, ref: a.ref, title: a.title }];
|
|
90
|
+
case 'detach': return ['detach', { id: a.id, what: a.what }];
|
|
91
|
+
case 'archive': return ['archive', { id: a.id, restore: !!a.restore }];
|
|
92
|
+
default: return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function serve(dir, { input = process.stdin, output = process.stdout, wait } = {}) {
|
|
96
|
+
const reply = (id, result, error) => output.write(`${JSON.stringify({ jsonrpc: '2.0', id, ...(error ? { error } : { result }) })}\n`);
|
|
97
|
+
const rl = createInterface({ input });
|
|
98
|
+
rl.on('line', async (line) => {
|
|
99
|
+
const m = parsed(line);
|
|
100
|
+
if (!m || m.id === undefined) return;
|
|
101
|
+
if (m.method === 'initialize') return reply(m.id, { protocolVersion: m.params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'binder', version: VERSION } });
|
|
102
|
+
if (m.method === 'tools/list') return reply(m.id, { tools: TOOLS });
|
|
103
|
+
if (m.method === 'ping') return reply(m.id, {});
|
|
104
|
+
if (m.method === 'tools/call') {
|
|
105
|
+
const { name, arguments: a = {} } = m.params ?? {};
|
|
106
|
+
const what = asked(name, a);
|
|
107
|
+
if (!what) return reply(m.id, undefined, { code: -32601, message: `no tool ${name}` });
|
|
108
|
+
const r = await ask(dir, what[0], what[1], wait ? { wait } : {});
|
|
109
|
+
return reply(m.id, { content: [{ type: 'text', text: r.ok ? r.text : `Not done: ${r.text}` }], ...(r.ok ? {} : { isError: true }) });
|
|
110
|
+
}
|
|
111
|
+
return reply(m.id, undefined, { code: -32601, message: `no method ${m.method}` });
|
|
112
|
+
});
|
|
113
|
+
return rl;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const HERE = fileURLToPath(import.meta.url);
|
|
117
|
+
const main = (() => { try { return realpathSync(process.argv[1] ?? '') === realpathSync(HERE); } catch { return false; } })();
|
|
118
|
+
if (main) {
|
|
119
|
+
const at = process.argv.indexOf('--dir');
|
|
120
|
+
const dir = findFolder({ dir: at >= 0 ? process.argv[at + 1] : undefined });
|
|
121
|
+
if (!dir) { process.stderr.write(`No ${FOLDER} folder: give --dir FOLDER.\n`); process.exit(2); }
|
|
122
|
+
serve(dir);
|
|
123
|
+
}
|