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 +21 -0
- package/README.md +53 -1
- package/bin/binder.mjs +98 -0
- package/docs/protocol.md +66 -0
- package/host/binder-host.mjs +134 -0
- package/host/binder-serve.mjs +99 -0
- package/package.json +51 -1
- package/server/binder.mjs +124 -0
- package/server/store.mjs +406 -0
- package/server/templates.mjs +98 -0
- package/ui/binder-ui.css +157 -0
- package/ui/binder-ui.js +393 -0
- package/ui/texts/en.json +12 -0
- package/ui/texts/fr.json +12 -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,53 @@
|
|
|
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 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
|
+
}
|
package/docs/protocol.md
ADDED
|
@@ -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
|
-
{
|
|
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
|
+
}
|