@tensor.chat/companion 0.4.3 → 0.4.6

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.
@@ -70,40 +70,55 @@ function runSetup({
70
70
  const { rows } = screen.size();
71
71
  const w = contentWidth();
72
72
  const step = STEP[st.screen];
73
- const steps = t('steps').map((name, i) => (i < step ? S.green(`✔ ${name}`) : i === step ? S.bold(S.cyan(`● ${name}`)) : S.dim(`○ ${name}`))).join(S.dim(' ─ '));
74
- const top = [
75
- '',
76
- ' ' + spread(`${S.cyan('◆')} ${S.bold('Tensor.Chat Companion')} ${S.dim(t('setup'))}`, S.dim([device, user].filter(Boolean).join(' · ')), w),
77
- ' ' + steps,
78
- '',
79
- ' ' + spread(S.bold(title), right, w),
80
- '',
81
- ];
82
- // the status, or the help for the line under the cursor; two lines at most
83
- const note = st.message ? wrap(st.message.text, w).slice(0, 2).map((l) => ' ' + S[st.message.tone](l)) : wrap(help, w).slice(0, 2).map((l) => ' ' + S.dim(l));
84
- const bottom = [
85
- ...(note.length ? note : ['']),
86
- ' ' + S.dim('─'.repeat(w)),
87
- ...keyLines(keys, w),
88
- ];
89
- const space = Math.max(3, rows - top.length - bottom.length - 1);
73
+ // A narrow screen names only the current step, and puts what does not fit beside
74
+ // the brand or the title on a line of its own
75
+ const stepLine = (named) => t('steps').map((name, i) => {
76
+ const label = (mark) => (named || i === step ? `${mark} ${name}` : mark);
77
+ return i < step ? S.green(label('✔')) : i === step ? S.bold(S.cyan(label('●'))) : S.dim(label('○'));
78
+ }).join(S.dim(' ─ '));
79
+ const steps = width(stepLine(true)) <= w ? stepLine(true) : stepLine(false);
80
+ const besides = (left, rightPart) => (!rightPart || width(left) + 1 + width(rightPart) <= w ? [spread(left, rightPart, w)] : [left, rightPart]);
81
+ const heading = width(title) <= w ? [S.bold(title)] : wrap(title, w).map((l) => S.bold(l));
82
+ const parts = layout.squeeze({
83
+ rows,
84
+ width: w,
85
+ brand: besides(`${S.cyan('◆')} ${S.bold('Tensor.Chat Companion')} ${S.dim(t('setup'))}`, S.dim([device, user].filter(Boolean).join(' · '))),
86
+ steps,
87
+ heading: [...heading.slice(0, -1), ...besides(heading[heading.length - 1], right)],
88
+ // the status, or the help for the line under the cursor
89
+ note: st.message ? wrap(st.message.text, w).map((l) => S[st.message.tone](l)) : wrap(help, w).map((l) => S.dim(l)),
90
+ rule: S.dim('─'.repeat(w)),
91
+ keys: keyLines(keys, w),
92
+ });
93
+ const { top, bottom, gap } = parts;
94
+ const space = Math.max(3, rows - top.length - bottom.length - gap);
90
95
  let start = 0;
91
96
  if (body.length > space) start = Math.min(Math.max(0, focus - Math.floor(space / 2)), body.length - space);
92
97
  const shown = body.slice(start, start + space);
93
98
  if (start > 0) shown[0] = ' ' + S.dim('↑');
94
99
  if (start + space < body.length) shown[shown.length - 1] = ' ' + S.dim('↓');
95
100
  while (shown.length < space) shown.push('');
96
- return [...top, ...shown, '', ...bottom];
101
+ return [...top, ...shown, ...(gap ? [''] : []), ...bottom];
97
102
  }
98
103
 
99
104
  const pointer = (on) => (on ? S.cyan('❯') : ' ');
105
+ // One line per choice, its note beside it; when one note does not fit there, every
106
+ // note goes under its choice. `at` is the line of the choice under the cursor.
100
107
  function radio(options, cursor) {
108
+ const w = contentWidth();
101
109
  const col = Math.min(LABEL_MAX, Math.max(...options.map((o) => width(o.label))) + 3);
102
- return options.map((o, i) => {
110
+ const below = options.some((o) => o.note && 4 + col + width(o.note) > w);
111
+ const lines = [];
112
+ options.forEach((o, i) => {
103
113
  const on = i === cursor;
104
- const note = o.note ? (o.tone ? S[o.tone](o.note) : S.dim(o.note)) : '';
105
- return ` ${pointer(on)} ${on ? S.cyan('●') : S.dim('○')} ${cell(on ? S.bold(o.label) : o.label, col)}${note}`;
114
+ const tone = (s) => (o.tone ? S[o.tone](s) : S.dim(s));
115
+ const head = ` ${pointer(on)} ${on ? S.cyan('●') : S.dim('○')} `;
116
+ if (on) lines.at = lines.length;
117
+ if (!o.note) lines.push(head + (on ? S.bold(o.label) : o.label));
118
+ else if (!below) lines.push(head + cell(on ? S.bold(o.label) : o.label, col) + tone(o.note));
119
+ else lines.push(head + (on ? S.bold(o.label) : o.label), ...wrap(o.note, w - 4).map((l) => ' ' + tone(l)));
106
120
  });
121
+ return lines;
107
122
  }
108
123
 
109
124
  function box(state) {
@@ -128,11 +143,13 @@ function runSetup({
128
143
  { label: t('start.nothing'), note: t('start.nothingNote') },
129
144
  { label: t('start.everything'), note: t('start.everythingNote'), tone: 'yellow' },
130
145
  ];
131
- const { cols } = screen.size();
132
- const body = [...radio(opts, st.cursor.start), '', ...wrap(t('start.help'), Math.min(cols - 6, CONTENT_MAX)).map((l) => ' ' + S.dim(l))];
146
+ const w = contentWidth();
147
+ const choices = radio(opts, st.cursor.start);
148
+ const body = [...choices, '', ...wrap(t('start.help'), w).map((l) => ' ' + S.dim(l))];
133
149
  if (initial.length) body.push('', ' ' + S.dim(t('start.now')), ...initial.slice(0, 4).map((r) => ` ${S.green('◼')} ${show(r)}`), ...(initial.length > 4 ? [' …'] : []));
134
- body.push('', ' ' + S.dim(t('start.later')) + ' ' + S.cyan('npx @tensor.chat/companion setup'));
135
- return frame({ title: t('start.title'), body, focus: st.cursor.start, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.quit]] });
150
+ const again = 'npx @tensor.chat/companion setup';
151
+ body.push('', ...(width(t('start.later')) + 1 + again.length <= w ? [' ' + S.dim(t('start.later')) + ' ' + S.cyan(again)] : [' ' + S.dim(t('start.later')), ' ' + S.cyan(again)]));
152
+ return frame({ title: t('start.title'), body, focus: choices.at, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.quit]] });
136
153
  }
137
154
 
138
155
  function placeRows() {
@@ -168,7 +185,8 @@ function runSetup({
168
185
  function placesView() {
169
186
  const rows = placeRows();
170
187
  const cur = Math.min(st.cursor.places, rows.length - 1);
171
- const labels = rows.filter((r) => r.type === 'place').map((r) => width(show(r.path)));
188
+ const name = (r) => layout.shorten(show(r.path), contentWidth() - 4);
189
+ const labels = rows.filter((r) => r.type === 'place').map((r) => width(name(r)));
172
190
  const col = Math.min(LABEL_MAX, Math.max(12, ...labels) + 3);
173
191
  const body = [];
174
192
  let focus = 0;
@@ -180,8 +198,10 @@ function runSetup({
180
198
  if (on) focus = body.length;
181
199
  if (r.type === 'place') {
182
200
  const state = tickState(r.path, st.selected, platform);
183
- const label = on ? S.bold(show(r.path)) : show(r.path);
184
- body.push(` ${pointer(on)} ${box(state)} ${cell(label, col)}${placeNote(r, state)}`);
201
+ const label = on ? S.bold(name(r)) : name(r);
202
+ const note = placeNote(r, state);
203
+ if (4 + Math.max(col, width(label) + 2) + width(note) <= contentWidth()) body.push(` ${pointer(on)} ${box(state)} ${cell(label, col)}${note}`);
204
+ else body.push(` ${pointer(on)} ${box(state)} ${label}`, ...wrap(note, contentWidth() - 4).map((l) => ' ' + l));
185
205
  } else if (r.type === 'continue') {
186
206
  body.push(` ${pointer(on)} ${on ? S.bold(S.cyan(t('places.continue') + ' →')) : S.cyan(t('places.continue') + ' →')}`);
187
207
  } else {
@@ -240,21 +260,26 @@ function runSetup({
240
260
  if (isRoot) text.push(S.red(t('everything.root')));
241
261
  text.push('', S.yellow(t('everything.free')));
242
262
  const body = [...panel(text, 'yellow'), ''];
243
- body.push(...radio([{ label: t('everything.no') }, { label: t('everything.yes'), tone: 'yellow' }], st.cursor.everything));
244
- return frame({ title: `${S.yellow('▲')} ${t('everything.title')}`, body, focus: body.length - 1, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.back]] });
263
+ const choices = radio([{ label: t('everything.no') }, { label: t('everything.yes'), tone: 'yellow' }], st.cursor.everything);
264
+ const focus = body.length + choices.at;
265
+ body.push(...choices);
266
+ return frame({ title: `${S.yellow('▲')} ${t('everything.title')}`, body, focus, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.back]] });
245
267
  }
246
268
 
247
269
  function shellOptions() {
248
270
  const quiet = t('shell.noNote');
249
271
  const list = shellUntil
250
- ? [{ id: 'keep', label: t('shell.keep'), note: t('shell.keepNote', { date: t.date(shellUntil) }) }, { id: 'off', label: t('shell.off'), note: quiet }]
272
+ ? [{ id: 'keep', label: t('shell.keep'), note: t('shell.keepNote', { date: t.date(shellUntil) }) }, { id: 'off', label: t('shell.off'), note: t('shell.offNote') }]
251
273
  : [{ id: 'no', label: t('shell.no'), note: quiet }];
252
274
  return [...list, { id: 7, label: t('shell.d7') }, { id: 30, label: t('shell.d30') }, { id: 365, label: t('shell.d365'), tone: 'yellow' }];
253
275
  }
254
276
 
255
277
  function shellView() {
256
- const body = [...panel([t('shell.body', { user: user || '?' })]), '', ...radio(shellOptions(), st.cursor.shell)];
257
- return frame({ title: t('shell.title'), body, focus: body.length - 1, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.back]] });
278
+ const body = [...panel([t('shell.body', { user: user || '?' })]), ''];
279
+ const choices = radio(shellOptions(), st.cursor.shell);
280
+ const focus = body.length + choices.at;
281
+ body.push(...choices);
282
+ return frame({ title: t('shell.title'), body, focus, keys: [['↑↓', K.move], [N.enter, K.select], [N.esc, K.back]] });
258
283
  }
259
284
 
260
285
  function changes() {
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const files = require('./tools/files');
5
+ const { fetchUrl } = require('./tools/fetch');
6
+ const { extractTouched } = require('./tools/touched');
7
+ const { outsideFolders } = require('./tools/outside');
8
+ const { Jobs } = require('./tools/jobs');
9
+
10
+ const BACKGROUND_FIRST_LOOK_MS = 1500;
11
+ const FOREGROUND_DEFAULT_MS = 600000;
12
+
13
+ function createToolExecutor({ shell, say = () => {}, slowSpawnMs = 2000 } = {}) {
14
+ const jobs = new Jobs();
15
+
16
+ async function runTool(tool, args, signal, timeoutMs, callRoots, requestedAt = Date.now(), cancelled = () => false) {
17
+ const a = args && typeof args === 'object' ? args : {};
18
+ if (signal.aborted || cancelled()) throw new Error('call cancelled before execution');
19
+ switch (tool) {
20
+ case 'bash': {
21
+ const cwdRes = a.cwd ? callRoots.resolve(a.cwd) : null;
22
+ if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
23
+ const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
24
+ if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
25
+ const spawnAt = Date.now();
26
+ const job = jobs.start(String(a.command || ''), { cwd, shell });
27
+ const spawnMs = Date.now() - spawnAt;
28
+ if (spawnMs >= slowSpawnMs) say(`starting the shell took ${Math.round(spawnMs / 1000)} s`);
29
+ // A background job belongs to the chat, not to the call that started it.
30
+ const onAbort = () => { if (!a.background) jobs.kill(job.id); };
31
+ signal.addEventListener('abort', onAbort, { once: true });
32
+ if (signal.aborted || cancelled()) onAbort();
33
+ const limit = timeoutMs || FOREGROUND_DEFAULT_MS;
34
+ const waitMs = a.background ? BACKGROUND_FIRST_LOOK_MS : Math.max(1000, limit - Math.min(5000, Math.floor(limit / 4)));
35
+ // The server's deadline includes time queued in the worker and time in spawn.
36
+ const ended = await jobs.wait(job, a.background ? waitMs : Math.max(0, waitMs - (Date.now() - requestedAt)));
37
+ signal.removeEventListener('abort', onAbort);
38
+ if (ended) {
39
+ const r = jobs.result(job);
40
+ r.touched = extractTouched(callRoots, a.command, r.stdout, r.stderr);
41
+ delete r.stdout;
42
+ delete r.stderr;
43
+ jobs.poll(job.id);
44
+ return r;
45
+ }
46
+ const p = jobs.poll(job.id);
47
+ return { ok: true, running: true, jobId: job.id, text: p.text, exitCode: null, truncated: false, timedOut: false, touched: [] };
48
+ }
49
+ case 'job': {
50
+ const job = jobs.get(a.jobId);
51
+ if (!job) return { ok: false, text: '(no such job on this device — it may have been forgotten after many newer ones)' };
52
+ if (a.action === 'kill') {
53
+ jobs.kill(job.id);
54
+ await jobs.wait(job, 3000);
55
+ }
56
+ const p = jobs.poll(job.id);
57
+ return { ok: true, jobId: job.id, running: p.status === 'running', status: p.status, exitCode: p.exitCode, jobMs: p.ms, text: p.text, skipped: p.skipped };
58
+ }
59
+ case 'read': return files.read(callRoots, a.path, { lines: a.lines });
60
+ case 'write': return files.write(callRoots, a.path, a.content, { overwrite: a.overwrite === true });
61
+ case 'edit': return files.edit(callRoots, a.path, a.edits);
62
+ case 'glob': return files.glob(callRoots, a.pattern);
63
+ case 'outside': {
64
+ const at = a.cwd ? callRoots.resolve(a.cwd) : null;
65
+ return outsideFolders(callRoots, a.command, { cwd: at && !at.error ? at.abs : callRoots.primary() || os.homedir() });
66
+ }
67
+ case 'grep': return files.grep(callRoots, a.command);
68
+ case 'ls': return files.ls(callRoots, a.path, { max: a.max });
69
+ case 'fetchfile': return files.fetchfile(callRoots, a.path, { maxBytes: a.maxBytes });
70
+ case 'fetchpart': return files.fetchpart(callRoots, a.path, { offset: a.offset, length: a.length });
71
+ // These operations are private to the mounted-drive adapter.
72
+ case 'writepart': return files.writepart(callRoots, a.path, { offset: a.offset, base64: a.base64, truncate: a.truncate });
73
+ case 'mkdirpath': return files.mkdirpath(callRoots, a.path);
74
+ case 'removepath': return files.removepath(callRoots, a.path, { endgueltig: a.endgueltig === true });
75
+ case 'renamepath': return files.renamepath(callRoots, a.from, a.to);
76
+ case 'statpath': return files.statpath(callRoots, a.path);
77
+ case 'fetch': return fetchUrl(a.url, { signal, raw: a.raw === true });
78
+ default: return { ok: false, text: `(unknown tool "${tool}" on the companion device)` };
79
+ }
80
+ }
81
+
82
+ return { runTool, jobs };
83
+ }
84
+
85
+ module.exports = { createToolExecutor };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { Worker } = require('worker_threads');
5
+ const EventEmitter = require('events');
6
+
7
+ // Even asynchronous spawn can block its calling thread inside Windows. Neither it
8
+ // nor synchronous filesystem walks may share the WebSocket's event loop.
9
+ class ToolRunner extends EventEmitter {
10
+ constructor({ shell, slowSpawnMs, workerPath = path.join(__dirname, 'toolWorker.js') } = {}) {
11
+ super();
12
+ this.options = { shell, slowSpawnMs };
13
+ this.workerPath = workerPath;
14
+ this.worker = null;
15
+ this.pending = new Map();
16
+ this.nextId = 1;
17
+ this.stopping = new Int32Array(new SharedArrayBuffer(4));
18
+ this.failure = null;
19
+ this.closed = null;
20
+ }
21
+
22
+ start() {
23
+ if (this.failure) throw this.failure;
24
+ if (Atomics.load(this.stopping, 0)) throw new Error('tool worker is stopped');
25
+ if (this.worker) return;
26
+ const worker = new Worker(this.workerPath, { workerData: { ...this.options, stopping: this.stopping.buffer } });
27
+ this.worker = worker;
28
+ this.closed = new Promise((resolve) => worker.once('exit', resolve));
29
+ const fail = (error) => {
30
+ this.failure = error;
31
+ for (const p of this.pending.values()) p.reject(error);
32
+ this.pending.clear();
33
+ };
34
+ worker.on('message', (msg) => {
35
+ if (msg.t === 'log') this.emit('log', msg.line);
36
+ else if (msg.t === 'job_exit') this.emit('exit', msg.report);
37
+ else if (msg.t === 'result') {
38
+ const p = this.pending.get(msg.id);
39
+ if (!p) return;
40
+ this.pending.delete(msg.id);
41
+ p.resolve(msg.result);
42
+ }
43
+ });
44
+ worker.on('error', fail);
45
+ worker.on('exit', (code) => {
46
+ // Never replay calls or reset job IDs after a crash: their side effects may exist.
47
+ fail(new Error(`tool worker exited (code ${code}); commands were not retried`));
48
+ });
49
+ }
50
+
51
+ async run(tool, args, signal, timeoutMs, roots) {
52
+ this.start();
53
+ const id = this.nextId++;
54
+ const cancelled = new Int32Array(new SharedArrayBuffer(4));
55
+ const abort = () => {
56
+ Atomics.store(cancelled, 0, 1);
57
+ this.worker.postMessage({ t: 'cancel', id });
58
+ };
59
+ signal.addEventListener('abort', abort, { once: true });
60
+ try {
61
+ if (signal.aborted) {
62
+ Atomics.store(cancelled, 0, 1);
63
+ throw new Error('call cancelled before execution');
64
+ }
65
+ return await new Promise((resolve, reject) => {
66
+ this.pending.set(id, { resolve, reject });
67
+ try {
68
+ this.worker.postMessage({ t: 'call', id, tool, args, timeoutMs, entries: roots.entries, requestedAt: Date.now(), cancelled: cancelled.buffer });
69
+ } catch (e) {
70
+ this.pending.delete(id);
71
+ reject(e);
72
+ }
73
+ });
74
+ } finally {
75
+ signal.removeEventListener('abort', abort);
76
+ }
77
+ }
78
+
79
+ stop() {
80
+ if (!Atomics.exchange(this.stopping, 0, 1) && this.worker) this.worker.postMessage({ t: 'stop' });
81
+ return this.closed || Promise.resolve();
82
+ }
83
+ }
84
+
85
+ module.exports = { ToolRunner };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const { parentPort, workerData } = require('worker_threads');
4
+ const { Roots } = require('./paths');
5
+ const { createToolExecutor } = require('./toolExecutor');
6
+
7
+ const stopping = new Int32Array(workerData.stopping);
8
+ const executor = createToolExecutor({
9
+ shell: workerData.shell,
10
+ slowSpawnMs: workerData.slowSpawnMs,
11
+ say: (line) => parentPort.postMessage({ t: 'log', line }),
12
+ });
13
+ const running = new Map();
14
+ executor.jobs.on('exit', (report) => parentPort.postMessage({ t: 'job_exit', report }));
15
+
16
+ parentPort.on('message', async (msg) => {
17
+ if (msg.t === 'cancel') {
18
+ const ctrl = running.get(msg.id);
19
+ if (ctrl) ctrl.abort();
20
+ return;
21
+ }
22
+ if (msg.t === 'stop') {
23
+ for (const ctrl of running.values()) ctrl.abort();
24
+ executor.jobs.killAll();
25
+ await Promise.all(executor.jobs.running().map((job) => executor.jobs.wait(job, 5000)));
26
+ parentPort.close();
27
+ return;
28
+ }
29
+ if (msg.t !== 'call') return;
30
+ const ctrl = new AbortController();
31
+ const cancelled = new Int32Array(msg.cancelled);
32
+ const isCancelled = () => !!Atomics.load(cancelled, 0) || !!Atomics.load(stopping, 0);
33
+ running.set(msg.id, ctrl);
34
+ let result;
35
+ try {
36
+ if (isCancelled()) throw new Error('call cancelled before execution');
37
+ const roots = new Roots([]);
38
+ // This is the authorized per-call snapshot, never the worker's previous roots.
39
+ roots.entries = msg.entries;
40
+ result = await executor.runTool(msg.tool, msg.args, ctrl.signal, msg.timeoutMs, roots, msg.requestedAt, isCancelled);
41
+ } catch (e) {
42
+ result = { ok: false, text: `(tool error on the companion device: ${e.message})` };
43
+ } finally {
44
+ running.delete(msg.id);
45
+ }
46
+ parentPort.postMessage({ t: 'result', id: msg.id, result });
47
+ });