clavarium-binder 0.1.2-next.1 → 0.1.2

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/docs/protocol.md CHANGED
@@ -53,8 +53,24 @@ A person's alone (from the screen): `approve`, `delete`, `edit-list`,
53
53
  `grant`, `archive-list`, `delete-list`; a note is changed by whoever wrote it
54
54
  (`edit-note`). `create-list` is a person's, and an agent's from a template
55
55
  (`binder new TEMPLATE ["title"]`, MCP `new-list`): the list is given to that
56
- agent alone, to change, and a person sees it as any; five such lists not
57
- archived per agent at most.
56
+ agent alone, to change, and a person sees it as any; per agent, as many such
57
+ lists not archived as the store's setting `agentLists` says.
58
+
59
+ ## The store's settings
60
+
61
+ A person's alone to change; anyone reads them. Each change is a line of the
62
+ log, op `edit-settings`, with what changed (`change`), and no list or record.
63
+
64
+ | Setting | What it is | Range | By default |
65
+ |---|---|---|---|
66
+ | `agentLists` | lists an agent may have made and not archived | 0 to 50 (0: an agent makes none) | 5 |
67
+
68
+ | Op (keeper) | Args | Gives |
69
+ |---|---|---|
70
+ | `settings` | none | `{agentLists}` |
71
+ | `edit-settings` | `{agentLists}` | `{agentLists}`; an agent is refused |
72
+
73
+ An agent at the limit is refused, and told the number.
58
74
 
59
75
  ## The application's side
60
76
 
@@ -71,6 +71,7 @@ export function say(op, data, list) {
71
71
  case 'attach': return 'Attached.';
72
72
  case 'detach': return 'Taken off.';
73
73
  case 'archive': return data.archived ? `Archived #${data.id}.` : `Restored #${data.id}.`;
74
+ case 'settings': case 'edit-settings': return `Lists an agent may make: ${data.agentLists}.`;
74
75
  case 'create-list': return `Made the list ${data.title} [${data.id}], yours to change: binder add ${data.id} "title".`;
75
76
  default: return 'Done.';
76
77
  }
@@ -105,6 +106,8 @@ export function keeper(dir, { sides = () => [], now = () => Date.now() } = {}) {
105
106
  case 'grant': data = store.grant(a.list, a.to, a.access ?? null, who); break;
106
107
  case 'archive-list': data = store.archiveList(a.list, a.archived !== false, who); break;
107
108
  case 'delete-list': data = store.deleteList(a.list, who); break;
109
+ case 'settings': data = store.settings(who); break;
110
+ case 'edit-settings': data = store.editSettings({ agentLists: a.agentLists }, who); break;
108
111
  case 'templates': data = Object.fromEntries(Object.entries(TEMPLATES).map(([k, t]) => [k, t.name])); break;
109
112
  default: return { ok: false, text: `there is no ${oneLine(op, 40)}` };
110
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium-binder",
3
- "version": "0.1.2-next.1",
3
+ "version": "0.1.2",
4
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
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/clavarium/clavarium-binder#readme",
package/server/binder.mjs CHANGED
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
23
23
  import { randomUUID } from 'node:crypto';
24
24
  import { createInterface } from 'node:readline';
25
25
 
26
- export const VERSION = '0.1.2-next.1';
26
+ export const VERSION = '0.1.2';
27
27
  export const FOLDER = '.binder';
28
28
  /* How long an ask waits for its answer. The keeper answers within a second or two. */
29
29
  export const WAIT_MS = 20_000;
package/server/store.mjs CHANGED
@@ -15,8 +15,12 @@
15
15
  * WHO is { id, kind, name }: kind "person" or "agent". A person may do
16
16
  * anything. An agent reads or writes a list only where it was given access, and
17
17
  * never approves: a record reaches a gate state (or one after it) only once a
18
- * person approved it. An agent may make a list from a template (a few at
19
- * most): it is given that list alone, to change, and a person sees it as any.
18
+ * person approved it. An agent may make a list from a template (as many as a
19
+ * person set, 5 unless they said): it is given that list alone, to change, and
20
+ * a person sees it as any.
21
+ *
22
+ * SETTINGS: the store's own, a person's alone to change, in the history too.
23
+ * agentLists lists an agent may have made and not archived (0 to 50; 0: none)
20
24
  */
21
25
  import { readFileSync, writeFileSync, appendFileSync, renameSync, mkdirSync, existsSync, lstatSync } from 'node:fs';
22
26
  import { join } from 'node:path';
@@ -24,8 +28,11 @@ import { randomUUID } from 'node:crypto';
24
28
  import { seed, TEMPLATES, VIEWS, FIELD_TYPES } from './templates.mjs';
25
29
 
26
30
  export const TITLE_MAX = 200;
27
- /* Lists an agent may have made and not archived. */
28
- export const AGENT_LISTS_MAX = 5;
31
+ /* Lists an agent may have made and not archived: 5 until a person sets it (settings.agentLists), 0 to 50. */
32
+ export const AGENT_LISTS_DEFAULT = 5;
33
+ export const AGENT_LISTS_TOP = 50;
34
+ /* The default, by its former name. */
35
+ export const AGENT_LISTS_MAX = AGENT_LISTS_DEFAULT;
29
36
  export const NOTE_MAX = 2000;
30
37
  /* A record's description: a few lines for a person, what it is and why (Markdown-ish: paragraphs, "- " lists, **bold**, links). */
31
38
  export const DESCRIPTION_MAX = 4000;
@@ -117,6 +124,9 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
117
124
  count: Object.values(s.records).filter((r) => r.list === l.id && !r.archived).length });
118
125
 
119
126
  /* Whether a record's title, description or notes hold `q` (lower case). */
127
+ /* The store's settings, as they are now (a store from before them has the defaults). */
128
+ const settingsNow = () => ({ agentLists: Number.isInteger(s.settings?.agentLists) ? s.settings.agentLists : AGENT_LISTS_DEFAULT });
129
+
120
130
  const said = (r, q) => r.title.toLowerCase().includes(q) || (r.description ?? '').toLowerCase().includes(q) || r.notes.some((n) => n.text.toLowerCase().includes(q));
121
131
 
122
132
  const api = {
@@ -172,7 +182,9 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
172
182
  if (Object.keys(s.lists).length >= LISTS_MAX) no(`${LISTS_MAX} lists at most`);
173
183
  if (!isPerson(w)) {
174
184
  if (!Object.hasOwn(TEMPLATES, String(template))) no(`a list is made from a template: ${Object.keys(TEMPLATES).join(', ')}`);
175
- if (Object.values(s.lists).filter((l) => l.by === w.id && !l.archived).length >= AGENT_LISTS_MAX) no(`${AGENT_LISTS_MAX} lists made by you at most: use one of yours`);
185
+ const most = settingsNow().agentLists;
186
+ if (most === 0) no('a person set 0 lists an agent may make: use one you were given');
187
+ if (Object.values(s.lists).filter((l) => l.by === w.id && !l.archived).length >= most) no(`${most} lists made by you at most, as a person set: use one of yours`);
176
188
  }
177
189
  const base = seed(template, lang);
178
190
  const name = oneLine(title, TITLE_MAX) || base.title;
@@ -247,6 +259,23 @@ export function openStore(dir, { now = () => Date.now() } = {}) {
247
259
  return true;
248
260
  },
249
261
 
262
+ /* ---- the store's settings: anyone reads them, a person changes them ---- */
263
+ settings(who) { whoOf(who); return settingsNow(); },
264
+ editSettings({ agentLists } = {}, who) {
265
+ const w = whoOf(who);
266
+ if (!isPerson(w)) no('only a person changes the settings');
267
+ const change = {};
268
+ if (agentLists !== undefined) {
269
+ const n = typeof agentLists === 'string' && agentLists.trim() !== '' ? Number(agentLists) : agentLists;
270
+ if (!Number.isInteger(n) || n < 0 || n > AGENT_LISTS_TOP) no(`lists an agent may make: a whole number from 0 to ${AGENT_LISTS_TOP}`);
271
+ change.agentLists = n;
272
+ }
273
+ if (!Object.keys(change).length) return settingsNow();
274
+ s.settings = { ...s.settings, ...change };
275
+ save(); log(w, 'edit-settings', { change });
276
+ return settingsNow();
277
+ },
278
+
250
279
  /* ---- records ---- */
251
280
  add(given, { title, description, state, fields, owners, due, note } = {}, who) {
252
281
  const w = whoOf(who);
package/ui/binder-ui.css CHANGED
@@ -149,6 +149,7 @@ button.binder-who:hover span:not(.binder-dot) { text-decoration: underline; text
149
149
  .binder-history { display: flex; flex-direction: column; gap: .3rem; margin-bottom: 1rem; }
150
150
  .binder-box { border: 1px solid var(--binder-line); border-radius: var(--binder-radius); background: var(--binder-panel); padding: .7rem .9rem; display: flex; flex-direction: column; gap: .55rem; min-width: 0; }
151
151
  .binder-box h3 { font-size: .75rem; letter-spacing: .06em; text-transform: uppercase; color: var(--binder-dim); display: flex; justify-content: space-between; }
152
+ .binder-box input[data-setting] { width: 6em; align-self: flex-start; }
152
153
  .binder-log { color: var(--binder-dim); font-size: .85em; }
153
154
  .binder-log time { font-variant-numeric: tabular-nums; margin-right: .6em; }
154
155
  .binder .binder-warn { color: var(--binder-danger); }
package/ui/binder-ui.js CHANGED
@@ -22,7 +22,7 @@ export const KEYS = Object.freeze(['lists', 'new-list', 'new-item', 'no-lists',
22
22
  'states', 'fields', 'access', 'read', 'write', 'none', 'save', 'empty', 'archived', 'show-archived', 'gate', 'done', 'add-state', 'add-field',
23
23
  'kind-message', 'kind-file', 'kind-asset', 'kind-branch', 'kind-link', 'kind-record', 'what', 'name', 'type', 'saved', 'edited', 'by', 'mine', 'all',
24
24
  'delete-list', 'delete-list-sure', 'archive-list', 'no-records', 'search', 'description', 'edit', 'more', 'approve', 'add', 'ok',
25
- 'add-what', 'owner', 'attachment', 'tools', 'cover',
25
+ 'add-what', 'owner', 'attachment', 'tools', 'cover', 'agent-lists',
26
26
  'op-add', 'op-edit', 'op-move', 'op-note', 'op-edit-note', 'op-remove-note', 'op-attach', 'op-detach', 'op-archive', 'op-restore', 'op-delete']);
27
27
  const KINDS = ['message', 'file', 'asset', 'branch', 'link', 'record'];
28
28
  const ICON = { message: '💬', file: '📄', asset: '🗄', branch: '⑂', link: '🔗', record: '▣' };
@@ -162,7 +162,7 @@ export function rich(text) {
162
162
  export function mount(root, { call, words = {}, lang = 'en', people = () => [], picture = () => null, openAttached = null } = {}) {
163
163
  const w = (k) => words[k] ?? k;
164
164
  /* open: what of a record is being changed ('states', 'who', 'description', 'attach'); draft: what was typed, kept across a repaint. */
165
- const ui = { lists: [], at: null, data: null, view: null, record: null, mode: 'list', archived: false, mine: false, error: '', open: null, more: false, draft: {}, focus: false };
165
+ const ui = { lists: [], at: null, data: null, view: null, record: null, mode: 'list', archived: false, mine: false, error: '', open: null, more: false, draft: {}, focus: false, settings: null };
166
166
  const box = h('div', { class: 'binder' });
167
167
  root.replaceChildren(box);
168
168
  let alive = true;
@@ -194,7 +194,7 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
194
194
  const menu = ui.open === 'tools' ? h('div', { class: 'binder-menu binder-menu-end', role: 'menu' },
195
195
  item(ui.mine, w('mine'), 'mine', () => flip('mine')),
196
196
  item(ui.archived, w('archived'), 'archived', () => flip('archived')),
197
- l.access === 'write' && l.grants ? item(null, w('settings'), 'settings', () => { ui.open = null; ui.mode = 'settings'; paint(); }) : null) : null;
197
+ l.access === 'write' && l.grants ? item(null, w('settings'), 'settings', () => { ui.open = null; ui.mode = 'settings'; paint(); void readSettings(); }) : null) : null;
198
198
  const chip = (k) => (ui[k] ? h('button', { type: 'button', class: 'binder-chip', 'aria-pressed': 'true', 'data-act': `off-${k}`, onclick: () => flip(k) }, w(k === 'mine' ? 'mine' : 'archived'), h('span', { 'aria-hidden': 'true' }, ' ×')) : null);
199
199
  return h('div', { class: 'binder-bar' },
200
200
  h('h2', {}, l.title), chip('mine'), chip('archived'),
@@ -423,6 +423,11 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
423
423
  attached, addRow, notes, more);
424
424
  }
425
425
 
426
+ /* The store's settings, read when its page opens; asked quietly (a keeper without them shows none). */
427
+ async function readSettings() {
428
+ try { const r = await call('settings', {}); ui.settings = r?.ok ? r.data : null; } catch { ui.settings = null; }
429
+ if (ui.mode === 'settings') paint();
430
+ }
426
431
  function settings(l) {
427
432
  const title = h('input', { type: 'text', value: l.title, 'aria-label': w('title') });
428
433
  const states = l.states.map((s) => ({ ...s }));
@@ -449,12 +454,18 @@ export function mount(root, { call, words = {}, lang = 'en', people = () => [],
449
454
  const sure = h('span', { class: 'binder-sure', hidden: true }, w('delete-list-sure'),
450
455
  h('button', { type: 'button', class: 'binder-danger', onclick: () => void soft(ask('delete-list', { list: l.id })).then(() => { ui.mode = 'list'; ui.at = null; return refresh(); }) }, w('delete')),
451
456
  h('button', { type: 'button', class: 'binder-ghost', onclick: () => { sure.hidden = true; } }, w('cancel')));
457
+ function agentLists() {
458
+ const n = h('input', { type: 'number', min: '0', max: '50', step: '1', inputmode: 'numeric', value: String(ui.settings.agentLists), 'aria-label': w('agent-lists'), 'data-setting': 'agentLists' });
459
+ n.addEventListener('change', () => void soft(ask('edit-settings', { agentLists: n.value })).then((got) => { if (got) ui.settings = got; paint(); }));
460
+ return n;
461
+ }
452
462
  return h('div', { class: 'binder-page binder-settings' },
453
463
  h('div', { class: 'binder-bar' }, h('button', { type: 'button', class: 'binder-ghost', onclick: () => { ui.mode = 'list'; paint(); } }, `‹ ${l.title}`)),
454
464
  h('section', { class: 'binder-box' }, h('h3', {}, w('title')), title),
455
465
  h('section', { class: 'binder-box' }, h('h3', {}, w('states')), stateRows),
456
466
  h('section', { class: 'binder-box' }, h('h3', {}, w('fields')), fieldRows),
457
467
  grants.length ? h('section', { class: 'binder-box' }, h('h3', {}, w('access')), grants) : null,
468
+ ui.settings ? h('section', { class: 'binder-box' }, h('h3', {}, w('agent-lists')), agentLists()) : null,
458
469
  h('div', { class: 'binder-actions' },
459
470
  h('button', { type: 'button', class: 'binder-ghost', onclick: () => void soft(ask('archive-list', { list: l.id })).then(() => { ui.mode = 'list'; ui.at = null; return refresh(); }) }, w('archive-list')),
460
471
  h('button', { type: 'button', class: 'binder-ghost', onclick: () => { sure.hidden = false; } }, w('delete-list')), sure,
package/ui/texts/en.json CHANGED
@@ -69,6 +69,7 @@
69
69
  "attachment": "Attachment",
70
70
  "tools": "More",
71
71
  "cover": "Cover",
72
+ "agent-lists": "Lists an agent may make",
72
73
  "op-add": "Created",
73
74
  "op-edit": "Edited",
74
75
  "op-move": "Moved",
package/ui/texts/fr.json CHANGED
@@ -69,6 +69,7 @@
69
69
  "attachment": "Pièce jointe",
70
70
  "tools": "Plus",
71
71
  "cover": "À la une",
72
+ "agent-lists": "Classeurs qu'une IA peut créer",
72
73
  "op-add": "Créée",
73
74
  "op-edit": "Modifiée",
74
75
  "op-move": "Déplacée",