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 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 (name reserved)
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
+ }
@@ -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
- {"name":"clavarium-binder","version":"0.0.1","description":"Clavarium Binder: name reserved; published from github.com/clavarium/clavarium-binder.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/clavarium/clavarium-binder.git"}}
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
+ }