clavarium-binder 0.0.1 → 0.1.1-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,53 @@
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 description, 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" --description "The whole site in dark, for evening reading."
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
+ The description is for a person, read at a glance: short and clear, what it is
20
+ and why, in 1 to 3 lines (paragraphs, `- ` lists, `**bold**` and links are
21
+ shown as such; 4000 characters at most). An agent writes one for each record it
22
+ adds.
23
+
24
+ An MCP server gives the same verbs to an AI agent. A person sees the binder on
25
+ its screen (`ui/`), drawn by Binder in the page of the application that runs it.
26
+
27
+ A sibling of [Clavarium Parley](https://github.com/clavarium/clavarium-parley) and
28
+ [Clavarium Docket](https://github.com/clavarium/clavarium-docket), built the same
29
+ way, universal (nothing in it is specific to Clavarium).
30
+
31
+ ## What an agent cannot do
32
+
33
+ - **Rewrite the binder.** It is held by its keeper (the application, or
34
+ `binder serve`), out of the agent's reach. An agent asks through its own
35
+ folder; the keeper answers, and says who asked by that folder.
36
+ - **Approve.** A list may have a gate (Batches: "Approved"): a record reaches
37
+ it, or any state after it, only once a person ticked it. An agent proposes,
38
+ and works on what was approved.
39
+ - **See what it was not given.** Access is given per list: read, or change.
40
+ - **Delete.** It archives; a person deletes, and deleted is deleted.
41
+
42
+ ## Without an application
43
+
44
+ binder serve --store ~/binder-data --side ./.binder=Assistant --lang fr
45
+
46
+ keeps a binder and shows its page on `http://127.0.0.1:4180/`. Put the store
47
+ where the agents cannot write.
48
+
49
+ See [docs/protocol.md](docs/protocol.md). No dependency; Node 22 or later.
50
+
51
+ **Status:** first version (0.1), not yet published.
52
+
53
+ License: MIT.
package/bin/binder.mjs ADDED
@@ -0,0 +1,98 @@
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" [--description "text"] [--state S] [--due DATE] [--owner me] [--note "text"] [--field name=value]
19
+ binder edit ID [--title "t"] [--description "text"] [--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
+ A description is for a person: short and clear, what it is and why, 1 to 3 lines
30
+ (Markdown-ish: "- " lists, **bold**, links). --description "" removes it.
31
+ You never approve: a person ticks what may go on. Talking with them stays in the chat.
32
+ The folder used is --dir FOLDER, else $BINDER_DIR, else the nearest ${FOLDER} folder from here up.
33
+ binder ${VERSION}`;
34
+
35
+ const VERBS = ['lists', 'show', 'get', 'search', 'add', 'edit', 'move', 'note', 'attach', 'detach', 'archive', 'restore'];
36
+
37
+ /* A command line, read: [op, args] or a refusal. */
38
+ export function read(argv) {
39
+ const args = [...argv];
40
+ const flags = {}; const fields = {}; const words = [];
41
+ for (let i = 0; i < args.length; i++) {
42
+ const a = args[i];
43
+ if (a === '--mine' || a === '--archived') { flags[a.slice(2)] = true; continue; }
44
+ 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; }
45
+ if (a.startsWith('--')) { if (args[i + 1] === undefined) return { refused: `${a} needs a value` }; flags[a.slice(2)] = args[++i]; continue; }
46
+ words.push(a);
47
+ }
48
+ const [verb = 'lists', ...rest] = words;
49
+ if (!VERBS.includes(verb)) return { refused: `Unknown: ${verb}. Run "binder --help".` };
50
+ const owners = flags.owner === undefined ? undefined : flags.owner === 'none' ? [] : [flags.owner];
51
+ const due = flags.due === 'none' ? '' : flags.due;
52
+ const has = Object.keys(fields).length ? { fields } : {};
53
+ switch (verb) {
54
+ case 'lists': return ['lists', {}];
55
+ 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' };
56
+ case 'get': return rest[0] ? ['get', { id: rest[0] }] : { refused: 'say which record: binder get ID' };
57
+ case 'search': return rest.length ? ['search', { text: rest.join(' ') }] : { refused: 'say what to look for' };
58
+ case 'add': return rest.length >= 2 ? ['add', { list: rest[0], title: rest.slice(1).join(' '), state: flags.state, due, owners, note: flags.note, description: flags.description, ...has }] : { refused: 'binder add LIST "title"' };
59
+ case 'edit': return rest[0] ? ['edit', { id: rest[0], title: flags.title, description: flags.description, due, owners, ...has }] : { refused: 'say which record: binder edit ID' };
60
+ case 'move': return rest.length === 2 ? ['move', { id: rest[0], state: rest[1] }] : { refused: 'binder move ID STATE' };
61
+ case 'note': return rest.length >= 2 ? ['note', { id: rest[0], text: rest.slice(1).join(' ') }] : { refused: 'binder note ID "text"' };
62
+ 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"]' };
63
+ case 'detach': return rest.length === 2 ? ['detach', { id: rest[0], what: rest[1] }] : { refused: 'binder detach ID WHAT' };
64
+ case 'archive': case 'restore': return rest[0] ? ['archive', { id: rest[0], restore: verb === 'restore' }] : { refused: `binder ${verb} ID` };
65
+ default: return { refused: 'not understood' };
66
+ }
67
+ }
68
+
69
+ /* Runs one command line. Resolves with { code, out, err }: nothing is printed here. */
70
+ export async function run(argv, { env = process.env, cwd = process.cwd(), wait } = {}) {
71
+ const args = [...argv];
72
+ let dir;
73
+ const at = args.indexOf('--dir');
74
+ if (at >= 0) { dir = args[at + 1]; if (!dir) return { code: 64, out: '', err: '--dir needs a folder.\n' }; args.splice(at, 2); }
75
+ if (['--help', '-h', 'help'].includes(args[0])) return { code: 0, out: `${HELP}\n`, err: '' };
76
+ if (args[0] === '--version') return { code: 0, out: `${VERSION}\n`, err: '' };
77
+ const what = read(args);
78
+ if (what.refused) return { code: 64, out: '', err: `${what.refused}\n` };
79
+ const folder = findFolder({ dir, env, from: cwd });
80
+ if (!folder) return { code: 2, out: '', err: `No ${FOLDER} folder here or above. Run it from your work folder, or give --dir FOLDER.\n` };
81
+ const r = await ask(folder, what[0], what[1], wait ? { wait } : {});
82
+ if (r.ok) return { code: 0, out: `${r.text}\n`, err: '' };
83
+ return { code: r.nokeeper ? 3 : 1, out: '', err: `Not done: ${r.text}\n` };
84
+ }
85
+
86
+ const main = (() => { try { return realpathSync(process.argv[1] ?? '') === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
87
+ if (main) {
88
+ const argv = process.argv.slice(2);
89
+ if (argv[0] === 'serve') {
90
+ const { serveBinder } = await import('../host/binder-serve.mjs');
91
+ await serveBinder(argv.slice(1));
92
+ } else {
93
+ const r = await run(argv);
94
+ if (r.out) process.stdout.write(r.out);
95
+ if (r.err) process.stderr.write(r.err);
96
+ process.exitCode = r.code;
97
+ }
98
+ }
@@ -0,0 +1,66 @@
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
+ `add` and `edit` take a `description` (`--description`): a few lines for a
48
+ person, what the record is and why; Markdown-ish (paragraphs, `- ` lists,
49
+ `**bold**`, `[text](https://...)` and bare http(s) links), 4000 characters at
50
+ most, empty to remove it. It is in the history like any other change.
51
+
52
+ A person's alone (from the screen): `approve`, `delete`, `create-list`,
53
+ `edit-list`, `grant`, `archive-list`, `delete-list`; a note is changed by
54
+ whoever wrote it (`edit-note`).
55
+
56
+ ## The application's side
57
+
58
+ import { keeper } from 'clavarium-binder/host';
59
+ const k = keeper(storeDir, { sides: () => [{ dir, who }] });
60
+ setInterval(() => k.pump(), 1000); // answer the agents
61
+ k.act(op, args, person); // a person's change: { ok, text, data }
62
+ k.due(); // records whose due time came: tell their owners
63
+ k.store.onChange((line) => ...); // every change, as the log says it
64
+
65
+ And the screen: `mount(element, { call, words, lang, people, picture, openAttached })`
66
+ from `clavarium-binder/ui`, with `ui/binder-ui.css` and `ui/texts/*.json`.
@@ -0,0 +1,134 @@
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
+ /* The first line of a description, as read: without its Markdown marks. */
31
+ export const firstLine = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*[-*]\s+/, '').replace(/\*\*/g, '').trim()).find(Boolean) ?? '';
32
+
33
+ /* ---- what an agent reads: plain lines ---- */
34
+ export function say(op, data, list) {
35
+ switch (op) {
36
+ case 'lists': return data.length === 0 ? 'You were given no list.'
37
+ : ['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'}`),
38
+ 'See one: binder show LIST'].join('\n');
39
+ case 'show': {
40
+ const { list: l, records } = data;
41
+ const out = [`${l.title} [${l.id}]${l.access === 'write' ? '' : ' (read only)'}: ${records.length} record${records.length === 1 ? '' : 's'}`];
42
+ for (const st of l.states) {
43
+ const here = records.filter((r) => r.state === st.id);
44
+ if (!here.length) continue;
45
+ out.push(`${st.name}${st.gate ? ' (a person approves)' : ''}:`);
46
+ for (const r of here) {
47
+ out.push(` #${r.id} ${r.title}${owners(r)}${due(r)}${r.attached.length ? ` [${r.attached.length} attached]` : ''}`);
48
+ if (r.description) out.push(` ${firstLine(r.description).slice(0, 120)}`);
49
+ }
50
+ }
51
+ if (l.states.some((x) => x.gate)) out.push('Past a gate, only what a person approved.');
52
+ return out.join('\n');
53
+ }
54
+ case 'get': {
55
+ const { record: r, list: l, history } = data;
56
+ const st = l.states.find((x) => x.id === r.state);
57
+ 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)}`];
58
+ if (r.description) out.push('Description:', ...r.description.split('\n').map((x) => (x ? ` ${x}` : '')));
59
+ else out.push('No description yet: give one (what it is and why, 1-3 lines for a person) with binder edit ID --description "...".');
60
+ for (const f of l.fields) if (r.fields[f.id] !== undefined) out.push(` ${f.name}: ${r.fields[f.id]}`);
61
+ 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)' : ''}`); }
62
+ 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}"` : ''}`); }
63
+ 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'}.`);
64
+ return out.join('\n');
65
+ }
66
+ case 'search': return data.length === 0 ? 'Nothing found.' : data.map((r) => `#${r.id} ${r.title} (${list(r.list)})`).join('\n');
67
+ case 'add': return `Added #${data.id}: ${data.title}.`;
68
+ case 'edit': return `Changed #${data.id}.`;
69
+ case 'move': return `#${data.id} is now ${data.stateName}.`;
70
+ case 'note': return 'Noted.';
71
+ case 'attach': return 'Attached.';
72
+ case 'detach': return 'Taken off.';
73
+ case 'archive': return data.archived ? `Archived #${data.id}.` : `Restored #${data.id}.`;
74
+ default: return 'Done.';
75
+ }
76
+ }
77
+
78
+ export function keeper(dir, { sides = () => [], now = () => Date.now() } = {}) {
79
+ const store = openStore(dir, { now });
80
+ const titleOf = (id) => store.lists({ id: 'keeper', kind: 'person' }).find((l) => l.id === id)?.title ?? id;
81
+ /* ONE ASK, as `who`: { ok, text, data }. An agent's "me" is its own id. */
82
+ const perform = (op, a = {}, who) => {
83
+ try {
84
+ const me = (list) => (Array.isArray(list) ? list.map((x) => (x === 'me' ? who.id : x)) : list);
85
+ let data;
86
+ switch (op) {
87
+ case 'lists': data = store.lists(who); break;
88
+ case 'show': data = store.list(a.list, who, { state: a.state, mine: a.mine, archived: a.archived, text: a.text }); break;
89
+ case 'get': data = store.record(a.id, who); break;
90
+ case 'search': data = store.search(a.text, who); break;
91
+ case 'add': data = store.add(a.list, { title: a.title, description: a.description, state: a.state, fields: a.fields, owners: me(a.owners), due: a.due, note: a.note }, who); break;
92
+ case 'edit': data = store.edit(a.id, { title: a.title, description: a.description, fields: a.fields, owners: me(a.owners), due: a.due }, who); break;
93
+ 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; }
94
+ case 'note': data = store.note(a.id, a.text, who); break;
95
+ case 'attach': data = store.attach(a.id, { kind: a.kind, ref: a.ref, title: a.title }, who); break;
96
+ case 'detach': data = store.detach(a.id, a.what, who); break;
97
+ case 'archive': data = store.archive(a.id, !a.restore, who); break;
98
+ /* A person's, from the page. */
99
+ case 'approve': data = store.approve(a.id, a.yes !== false, who); break;
100
+ case 'delete': data = store.delete(a.id, who); break;
101
+ case 'edit-note': data = store.editNote(a.id, a.note, a.text, who); break;
102
+ case 'create-list': data = store.createList({ template: a.template, title: a.title, lang: a.lang }, who); break;
103
+ case 'edit-list': data = store.editList(a.list, { title: a.title, view: a.view, states: a.states, fields: a.fields }, who); break;
104
+ case 'grant': data = store.grant(a.list, a.to, a.access ?? null, who); break;
105
+ case 'archive-list': data = store.archiveList(a.list, a.archived !== false, who); break;
106
+ case 'delete-list': data = store.deleteList(a.list, who); break;
107
+ case 'templates': data = Object.fromEntries(Object.entries(TEMPLATES).map(([k, t]) => [k, t.name])); break;
108
+ default: return { ok: false, text: `there is no ${oneLine(op, 40)}` };
109
+ }
110
+ return { ok: true, text: say(op, data, titleOf), data };
111
+ } catch (error) {
112
+ if (error instanceof Refused) return { ok: false, text: error.message };
113
+ throw error;
114
+ }
115
+ };
116
+ /* What each folder asked and was not answered yet. Answered asks are known by the answers file. */
117
+ const pump = () => {
118
+ let n = 0;
119
+ for (const side of sides()) {
120
+ const answered = new Set(lines(join(side.dir, 'answers.jsonl')).map((a) => a.ask));
121
+ for (const q of lines(join(side.dir, 'asks.jsonl'))) {
122
+ if (typeof q.id !== 'string' || answered.has(q.id)) continue;
123
+ const late = !(now() - Date.parse(q.at) < TOO_LATE_MS);
124
+ const answer = late ? { ok: false, text: 'too late: this ask waited too long and was not done' }
125
+ : perform(oneLine(q.op, 40), q.args && typeof q.args === 'object' ? q.args : {}, side.who);
126
+ mkdirSync(side.dir, { recursive: true });
127
+ appendFileSync(join(side.dir, 'answers.jsonl'), `${JSON.stringify({ ask: q.id, at: new Date(now()).toISOString(), ok: answer.ok, text: answer.text })}\n`);
128
+ answered.add(q.id); n++;
129
+ }
130
+ }
131
+ return n;
132
+ };
133
+ return { store, pump, act: perform, due: () => store.due() };
134
+ }
@@ -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.1-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
+ }