@rfany/rf 0.1.0 → 0.3.0

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/rf.mjs +100 -55
package/README.md CHANGED
@@ -5,4 +5,4 @@ People's command line for [rollforward](https://github.com/spoj/rollforward): wh
5
5
  npm i -g @rfany/rf
6
6
  rf login https://rollforward.example.com TOKEN
7
7
 
8
- Make the token on the page, under Tokens. `rf` alone lists the commands.
8
+ Make the token on the page, under Tokens. `rf` alone lists the commands; `rf help` is the guide for an agent, read before it first uses rf.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rfany/rf",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "rollforward's command line for people: what its page does, from a shell or a desktop agent, as you",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/spoj/rollforward.git", "directory": "cli" },
package/rf.mjs CHANGED
@@ -1,18 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  // rf for people: what the page does, from a shell or a desktop agent, as you, with a personal token made on the page (Tokens).
3
3
  // Published to npm as @rfany/rf, from cli/. PATH is workspace/series/label, as `rf ls` prints it.
4
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
7
7
 
8
8
  const USAGE = `rf login URL TOKEN
9
9
  rf ls
10
- rf pull DIR a read-only mirror of what you can see, in the layout agents see
11
- rf comment PATH TEXT [--item ID] [--ref REF] [--thread T] [--wait]
12
- rf answer PATH NOTE CHOICE CHOICE the option's number, from 0
10
+ rf pull PATH DIR [--delete] [-n|--dry-run] [--exclude PATTERN]...
11
+ PATH's files into DIR as rsync copies them, whole, if their size or time differ: PATH/ fills DIR,
12
+ PATH makes DIR/<its name>, / is all you see; --delete removes what the server no longer has
13
+ rf help how rollforward works and how to use rf, for an agent
14
+ rf comment PATH TEXT [--item ID] [--ref REF] [--thread T] [--wait] a new thread, or a reply in T, as to an agent's ask
13
15
  rf upload PATH FILE [--onto ITEM] [--output]
14
16
  rf wake PATH AT REASON [--label LABEL] AT now, +<n>m|h|d or a date; PATH an open instance, or a series with LABEL
15
- rf guidance [WORKSPACE] [--set FILE|-] the company's, or a workspace's; setting the company's is for admins
17
+ rf agents [WORKSPACE] [--set FILE|-] the company's AGENTS.md, or a workspace's; setting the company's is for admins
16
18
  rf skills
17
19
  rf websites
18
20
 
@@ -28,13 +30,52 @@ rf website set ID URL [--name NAME] [--hosts HOST,...] a website people sign i
28
30
  such as sso.example.com or *.example.com; a run's browser on it reaches only those
29
31
  rf website remove ID`;
30
32
 
33
+ const GUIDE = `rollforward runs a team's recurring work, such as a weekly borrowing base or a month-end reconciliation, with an agent
34
+ that does each period's work and people who check it. rf does what its page does, as you.
35
+
36
+ What there is
37
+ - A workspace holds series, and its people. A series is one recurring job; an instance is one period of it, labelled such as
38
+ 2026-08-23. PATH is workspace/series/label, as rf ls prints it; for rf pull it may go deeper, into an instance's files.
39
+ - An instance's screen is its items: inputs, steps and outputs, each pointing at a file, with what it's made from. People upload
40
+ files and comment; each comment or upload starts a run of the agent, which reads it and carries on. The runner is the person whose
41
+ logins the runs use, and who closes the instance once they stand by it.
42
+ - A comment starts a thread, on the instance or on an item, at a cell such as Base!C25, and others reply in it. An ask is the
43
+ agent's comment that waits on someone, the one it names or else the runner, until they reply or resolve it. rf ls says how many
44
+ asks wait in each instance.
45
+
46
+ What rf pull copies, the folders as the agent sees them
47
+ AGENTS.md the company's conventions; <workspace>/AGENTS.md the workspace's
48
+ <workspace>/<series>/<label>/
49
+ workspace.json the screen: {rev, status, items: [{id, name, kind, file, from, headline, source}]}
50
+ log.jsonl every event, by seq: by is a person's email, "server" or "agent"; op is upload, comment (thread, item,
51
+ ref, text, ask, to), resolve, point and item (the screen changing), run, report (the agent's account of
52
+ a run), done (its cost, summary or error), close, and the rest
53
+ upload/ what people uploaded, their comments' attachments in upload/attachments/
54
+ working/ what the agent made, its scripts, and process.md, how it does this series
55
+ session/ the agent's own session: every message and tool call of every run
56
+ browser/ each run's browser requests, and its screenshots
57
+ The copy is read-only, and only rf changes what's on the server: comment, upload and wake.
58
+
59
+ Usual work
60
+ - What waits on me: rf ls, then the asks in that instance's log.jsonl, ask true, whose thread has nothing after them.
61
+ - Answer an ask: rf comment PATH TEXT --thread T --wait, which prints what the agent says back.
62
+ - Give an input or a corrected output: rf upload PATH FILE [--onto ITEM] [--output].
63
+ - Start an instance: rf wake SERIES now REASON --label LABEL; take one up later: rf wake PATH +2h REASON.
64
+ - Read what a run did: rf pull PATH/ DIR, then its report and done entries in log.jsonl, the files in working/, and session/ for
65
+ every step.`;
66
+
31
67
  const config = join(homedir(), '.config', 'rollforward.json');
32
68
  const [cmd, ...argv] = process.argv.slice(2);
33
- const flags = {}, args = [];
69
+ const flags = {}, excludes = [], args = [];
34
70
  for (let i = 0; i < argv.length; i++) {
35
- const flag = argv[i].match(/^--(.+)$/)?.[1];
71
+ const flag = argv[i] === '-n' ? 'dry-run' : argv[i].match(/^--(.+)$/)?.[1];
36
72
  if (!flag) args.push(argv[i]);
37
- else flags[flag] = flag === 'wait' || flag === 'output' ? true : argv[++i];
73
+ else if (flag === 'exclude') excludes.push(argv[++i]);
74
+ else flags[flag] = ['wait', 'output', 'delete', 'dry-run'].includes(flag) ? true : argv[++i];
75
+ }
76
+ if (cmd === 'help') {
77
+ console.log(GUIDE);
78
+ process.exit();
38
79
  }
39
80
  if (cmd !== 'login' && !existsSync(config)) {
40
81
  console.error(`Sign in first, with a token made on the page under Tokens: rf login URL TOKEN\n\n${USAGE}`);
@@ -57,56 +98,63 @@ async function find(path ) {
57
98
  return { series, inst, at: inst ? `/api/instances/${inst.id}` : '' };
58
99
  }
59
100
 
60
- function write(path , data ) {
101
+ function write(path , data , mtime ) {
61
102
  mkdirSync(dirname(path), { recursive: true });
62
103
  rmSync(path, { force: true });
63
104
  writeFileSync(path, data);
105
+ utimesSync(path, new Date(mtime), new Date(mtime));
64
106
  chmodSync(path, 0o444);
65
107
  }
66
108
 
67
109
  // A path the server named, under `base`, or undefined: on Windows a file's name may hold \ and lead out of it.
68
110
  const under = (base , rel ) => resolve(base, rel).startsWith(resolve(base) + sep) ? resolve(base, rel) : undefined;
69
111
 
70
- // Pulls what changed since the last pull, by each instance's files' sizes and times, and removes what moved or went. A file that fails
71
- // is said and fetched again next time.
72
- async function pull(dir ) {
73
- const manifest = join(dir, '.rf-pull.json'), had = existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')) : {};
74
- const now = {}, tree = await get('/api');
75
- write(join(dir, 'GUIDANCE.md'), (await get('/guidance')).text);
76
- for (const w of tree) {
77
- write(join(dir, w.name, 'GUIDANCE.md'), w.guidance);
78
- for (const s of w.series) for (const i of s.instances) now[i.id] = { path: join(w.name, s.name, i.label), files: {} };
79
- }
80
- for (const [id, old] of Object.entries (had)) if (now[id]?.path !== old.path) rmSync(join(dir, old.path), { recursive: true, force: true });
81
- let fetched = 0;
82
- for (const [id, mine] of Object.entries(now)) {
83
- const at = `/api/instances/${id}`, d = await get(at), was = had[id]?.path === mine.path ? had[id].files : {};
84
- write(join(dir, mine.path, 'screen.json'), JSON.stringify(d.doc, null, 1));
85
- write(join(dir, mine.path, 'log.jsonl'), d.log.map(e => JSON.stringify(e)).join('\n') + '\n');
86
- write(join(dir, mine.path, 'trace.json'), JSON.stringify(await get(`${at}/trace`), null, 1));
87
- for (const f of await get(`${at}/files`)) {
88
- const path = under(join(dir, mine.path), f.path);
89
- if (!path) {
90
- console.error(`skipped ${mine.path}/${f.path}: it leads out of its folder`);
91
- continue;
92
- }
93
- mine.files[f.path] = `${f.size} ${f.mtime}`;
94
- if (was[f.path] === mine.files[f.path]) continue;
95
- try {
96
- write(path, new Uint8Array(await (await api(`/files/${id}/${f.path.split('/').map(encodeURIComponent).join('/')}`)).arrayBuffer()));
97
- fetched++;
98
- } catch (e) {
99
- mine.files[f.path] = '';
100
- console.error(`${mine.path}/${f.path}: ${(e ).message}`);
101
- }
112
+ // rsync's --exclude, simply: a pattern without / matches any one name on the path, one with / the path from the pull's top; * and ?
113
+ // stop at /, ** doesn't. A folder it matches takes what's in it along.
114
+ const globs = excludes.map(p => [p.replace(/^\/|\/$/g, '').includes('/'), new RegExp(`^${p.replace(/^\/|\/$/g, '').replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\0').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\0/g, '.*')}$`)] );
115
+ const excluded = (rel ) => globs.some(([whole, re]) => rel.split('/').some((name, k, all) => re.test(whole ? all.slice(0, k + 1).join('/') : name)));
116
+
117
+ // Copies PATH's files into DIR as rsync does, from the server only and whole: a file whose size or time differ from the server's is
118
+ // fetched, and given the server's time, so the next pull compares against it. Nothing is kept besides the files.
119
+ async function pull(path , dir ) {
120
+ const at = path.replace(/^\/+/, ''), from = at.replace(/\/+$/, ''), dry = !!flags['dry-run'];
121
+ const list = await get(`/api/files?path=${encodeURIComponent(from)}`);
122
+ if (!list.length) throw new Error(`nothing you can see at ${path}`);
123
+ const into = !from || at.endsWith('/') ? resolve(dir) : join(resolve(dir), basename(from)), rel = (f ) => f.path.slice(from ? from.length + 1 : 0);
124
+ let fetched = 0, same = 0, deleted = 0, failed = 0;
125
+ for (const f of list.filter(f => !excluded(rel(f)))) {
126
+ const local = rel(f) ? under(into, rel(f)) : into, st = local && existsSync(local) ? statSync(local) : undefined;
127
+ if (!local) {
128
+ console.error(`skipped ${f.path}: it leads out of ${into}`);
129
+ continue;
130
+ }
131
+ if (st && st.size === f.size && Math.floor(st.mtimeMs / 1000) === Math.floor(Date.parse(f.mtime) / 1000)) {
132
+ same++;
133
+ continue;
102
134
  }
103
- for (const gone of Object.keys(was).filter(p => !(p in mine.files))) {
104
- const path = under(join(dir, mine.path), gone);
105
- if (path) rmSync(path, { force: true });
135
+ console.log(`${st ? 'changed' : 'new'} ${f.path}`);
136
+ try {
137
+ if (!dry) write(local, f.text ?? new Uint8Array(await (await api(f.url )).arrayBuffer()), f.mtime);
138
+ fetched++;
139
+ } catch (e) {
140
+ failed++;
141
+ console.error(`${f.path}: ${(e ).message}`);
142
+ }
143
+ }
144
+ if (flags.delete && list.some(rel) && existsSync(into)) {
145
+ const keep = new Set(list.map(rel)), mine = (d ) => readdirSync(d, { withFileTypes: true }).flatMap(e => e.isDirectory() ? mine(join(d, e.name)) : [join(d, e.name)]);
146
+ for (const p of mine(into)) {
147
+ const r = relative(into, p).split(sep).join('/');
148
+ if (keep.has(r) || excluded(r)) continue;
149
+ console.log(`deleted ${r}`);
150
+ deleted++;
151
+ if (!dry) rmSync(p, { force: true });
106
152
  }
153
+ const prune = (d ) => { for (const e of readdirSync(d, { withFileTypes: true })) if (e.isDirectory()) prune(join(d, e.name)); if (d !== into && !readdirSync(d).length) rmdirSync(d); };
154
+ if (!dry) prune(into);
107
155
  }
108
- writeFileSync(manifest, JSON.stringify(now));
109
- console.log(`${Object.keys(now).length} instances, ${fetched} files fetched, into ${dir}`);
156
+ console.log(`${dry ? 'would fetch' : 'fetched'} ${fetched}, ${same} up to date${flags.delete ? `, ${dry ? 'would delete' : 'deleted'} ${deleted}` : ''}${failed ? `, ${failed} failed` : ''}, in ${into}`);
157
+ if (failed) process.exitCode = 1;
110
158
  }
111
159
 
112
160
  if (cmd === 'login' && url && token) {
@@ -117,9 +165,9 @@ if (cmd === 'login' && url && token) {
117
165
  } else if (cmd === 'ls') {
118
166
  for (const w of await get('/api')) for (const s of w.series) {
119
167
  console.log(`${w.name}/${s.name}${s.wake ? ` wakes ${s.wake.at} to start ${s.wake.label}: ${s.wake.reason}` : ''}${s.requests.length ? ` ${s.requests.length} asking to start` : ''}`);
120
- for (const i of s.instances) console.log(`${w.name}/${s.name}/${i.label} ${i.status}${i.running ? ', running' : ''}${i.questions.length ? `, ${i.questions.length} questions` : ''}${i.wake ? `, wakes ${i.wake}` : ''}, runner ${i.runner}`);
168
+ for (const i of s.instances) console.log(`${w.name}/${s.name}/${i.label} ${i.status}${i.running ? ', running' : ''}${i.asks.length ? `, ${i.asks.length} asks waiting` : ''}${i.wake ? `, wakes ${i.wake}` : ''}, runner ${i.runner}`);
121
169
  }
122
- } else if (cmd === 'pull' && args[0]) await pull(args[0]);
170
+ } else if (cmd === 'pull' && args.length === 2) await pull(args[0], args[1]);
123
171
  else if (cmd === 'comment' && args.length === 2) {
124
172
  const { at } = await find(args[0]);
125
173
  await post(`${at}/comment`, { text: args[1], item: flags.item, ref: flags.ref, thread: flags.thread });
@@ -131,13 +179,10 @@ else if (cmd === 'comment' && args.length === 2) {
131
179
  const log = (await get(at)).log, done = log.find(e => e.op === 'done' && e.seen >= seq);
132
180
  if (!done) continue;
133
181
  const from = Math.max(seq, log.find(e => e.op === 'run' && e.run === done.run).seq);
134
- for (const e of log.filter(e => e.seq > from && e.seq < done.seq && ((e.op === 'comment' && e.by === 'agent') || e.op === 'report'))) console.log(`${e.op === 'comment' ? `In thread ${e.thread}` : 'Report'}:\n${e.text}\n`);
182
+ for (const e of log.filter(e => e.seq > from && e.seq < done.seq && ((e.op === 'comment' && e.by === 'agent') || e.op === 'report'))) console.log(`${e.op === 'report' ? 'Report' : `${e.ask ? 'Asks' : 'Says'} in ${e.thread}`}:\n${e.text}\n`);
135
183
  if (done.error) console.log(`The run failed: ${done.error}`);
136
184
  break;
137
185
  }
138
- } else if (cmd === 'answer' && args.length === 3) {
139
- await post(`${(await find(args[0])).at}/answer`, { note: args[1], choice: Number(args[2]) });
140
- console.log('answered');
141
186
  } else if (cmd === 'upload' && args.length === 2) {
142
187
  const q = new URLSearchParams({ name: basename(args[1]), pane: flags.output ? 'out' : 'in', ...(typeof flags.onto === 'string' && { onto: flags.onto }) });
143
188
  await api(`${(await find(args[0])).at}/upload?${q}`, { method: 'POST', body: readFileSync(args[1]) });
@@ -147,13 +192,13 @@ else if (cmd === 'comment' && args.length === 2) {
147
192
  const at = args[1] === 'now' ? Date.now() : args[1].startsWith('+') ? Date.now() + Number(args[1].slice(1, -1)) * { m: 60e3, h: 3600e3, d: 86400e3 }[args[1].at(-1) ] : Date.parse(args[1]);
148
193
  const w = await (await post(`/api/wakes/${inst?.id ?? series.id}`, { at: new Date(at).toISOString(), reason: args.slice(2).join(' '), label: flags.label }, 'PUT')).json();
149
194
  console.log(`wakes at ${w.at}`);
150
- } else if (cmd === 'guidance' && args.length <= 1) {
195
+ } else if (cmd === 'agents' && args.length <= 1) {
151
196
  const ws = args[0] === undefined ? undefined : (await get('/api')).find(w => w.name === args[0]);
152
197
  if (args[0] !== undefined && !ws) throw new Error(`no workspace ${args[0]}`);
153
- if (typeof flags.set !== 'string') console.log(ws ? ws.guidance : (await get('/guidance')).text);
198
+ if (typeof flags.set !== 'string') console.log(ws ? ws.agents : (await get('/agents')).text);
154
199
  else {
155
200
  const text = readFileSync(flags.set === '-' ? 0 : flags.set, 'utf8');
156
- await (ws ? post(`/api/workspaces/${ws.id}`, { guidance: text }, 'PUT') : api('/guidance', { method: 'PUT', body: text }));
201
+ await (ws ? post(`/api/workspaces/${ws.id}`, { agents: text }, 'PUT') : api('/agents', { method: 'PUT', body: text }));
157
202
  console.log('saved');
158
203
  }
159
204
  } else if (cmd === 'skill' && args[0] === 'push' && args[1]) {