@tensor.chat/companion 0.4.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensor.chat/companion",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Lets your tensor.chat AI use this machine: files, shell and internet through your own connection.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://tensor.chat",
package/src/daemon.js CHANGED
@@ -15,6 +15,7 @@ const { fetchUrl } = require('./tools/fetch');
15
15
  const { extractTouched } = require('./tools/touched');
16
16
  const { outsideFolders } = require('./tools/outside');
17
17
  const { Jobs } = require('./tools/jobs');
18
+ const { createStallWatch } = require('./stalls');
18
19
 
19
20
  const VERSION = require('../package.json').version;
20
21
  const RECONNECT_MIN_MS = 1000;
@@ -22,6 +23,17 @@ const RECONNECT_MAX_MS = 60000;
22
23
  const MAX_CONCURRENT_CALLS = 4;
23
24
  const BACKGROUND_FIRST_LOOK_MS = 1500; // a background start still reports the first lines
24
25
  const FOREGROUND_DEFAULT_MS = 600000;
26
+ const SLOW_SPAWN_MS = 2000;
27
+
28
+ // What a call works on, for the log: the command, the path or the address
29
+ function callSubject(tool, args) {
30
+ const a = args && typeof args === 'object' ? args : {};
31
+ if (tool === 'bash' || tool === 'grep') return a.command;
32
+ if (tool === 'glob') return a.pattern;
33
+ if (tool === 'fetch') return a.url;
34
+ if (tool === 'renamepath') return a.from;
35
+ return a.path;
36
+ }
25
37
 
26
38
  function wsUrlFor(serverUrl) {
27
39
  const u = new URL(serverUrl);
@@ -83,7 +95,7 @@ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
83
95
  return { roots, labels: keptLabels, rejected };
84
96
  }
85
97
 
86
- function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell } = {}) {
98
+ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, slowSpawnMs = SLOW_SPAWN_MS } = {}) {
87
99
  if (!serverUrl || !token) throw new Error('serverUrl and token are required');
88
100
  const emitter = new EventEmitter();
89
101
  const say = typeof log === 'function' ? log : () => {};
@@ -93,7 +105,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
93
105
  const running = new Map(); // call id → AbortController
94
106
  const jobs = new Jobs();
95
107
  const unsentExits = []; // job endings the server has not heard yet (offline at the time)
96
- const state = { connected: false, deviceId: null, name: null, mode: null, roots: [], labels: [], reconnectMs: RECONNECT_MIN_MS, stopped: false, ws: null, timer: null };
108
+ const state = { connected: false, deviceId: null, name: null, mode: null, roots: [], labels: [], reconnectMs: RECONNECT_MIN_MS, stopped: false, ws: null, timer: null, connectedAt: null, lastPingAt: null };
109
+ const stalls = createStallWatch({ say });
97
110
 
98
111
  function send(msg) {
99
112
  if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
@@ -122,7 +135,13 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
122
135
  if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
123
136
  const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
124
137
  if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
138
+ // Starting a process holds this whole process until Windows has it running. A
139
+ // scanner that inspects the program first can make that last long enough for
140
+ // the server to stop hearing from this computer.
141
+ const spawnAt = Date.now();
125
142
  const job = jobs.start(String(a.command || ''), { cwd, shell });
143
+ const spawnMs = Date.now() - spawnAt;
144
+ if (spawnMs >= slowSpawnMs) say(`starting the shell took ${Math.round(spawnMs / 1000)} s`);
126
145
  // A cancelled call (turn aborted, server timeout) takes its foreground job with
127
146
  // it; a background job belongs to the chat, not to the call, and lives on.
128
147
  const onAbort = () => { if (!a.background) jobs.kill(job.id); };
@@ -196,6 +215,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
196
215
  }
197
216
  const ctrl = new AbortController();
198
217
  running.set(id, ctrl);
218
+ const finished = stalls.started(tool, callSubject(tool, msg.args));
199
219
  emitter.emit('call', { id, tool: msg.tool, args: msg.args });
200
220
  let r;
201
221
  try {
@@ -209,6 +229,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
209
229
  } catch (e) {
210
230
  r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
211
231
  }
232
+ finished();
212
233
  running.delete(id);
213
234
  send({ t: 'result', id, ...r });
214
235
  emitter.emit('result', { id, tool: msg.tool, ok: r.ok });
@@ -248,6 +269,16 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
248
269
  }
249
270
  }
250
271
 
272
+ // 1006 without a reason is a connection that just ended: the server stopped hearing
273
+ // pings, or the network went. The last ping says which side went quiet first.
274
+ function closeLine(code, reason, at = Date.now()) {
275
+ const why = String(reason).trim();
276
+ const secs = (ms) => `${Math.round(ms / 1000)} s`;
277
+ return `disconnected: code ${code}${why ? ` "${why}"` : ''}, connected for ${secs(at - state.connectedAt)}`
278
+ + `, last ping from the server ${state.lastPingAt ? `${secs(at - state.lastPingAt)} ago` : 'never'}`
279
+ + (running.size ? `, ${running.size} call(s) running` : '');
280
+ }
281
+
251
282
  function scheduleReconnect() {
252
283
  if (state.stopped || state.timer) return;
253
284
  const wait = state.reconnectMs;
@@ -270,6 +301,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
270
301
  state.ws = ws;
271
302
  ws.on('open', () => {
272
303
  state.connected = true;
304
+ state.connectedAt = Date.now();
305
+ state.lastPingAt = null;
273
306
  state.reconnectMs = RECONNECT_MIN_MS;
274
307
  send(systemInfo(shell, !!localApprovals && !!localShellApproval));
275
308
  flushExits();
@@ -277,6 +310,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
277
310
  emitter.emit('connected');
278
311
  });
279
312
  ws.on('message', onMessage);
313
+ ws.on('ping', () => { state.lastPingAt = Date.now(); });
280
314
  ws.on('unexpected-response', (_req, res) => {
281
315
  const code = res && res.statusCode;
282
316
  say(`server refused the connection (HTTP ${code})`);
@@ -290,6 +324,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
290
324
  ws.on('error', (e) => { say(`socket error: ${e.message}`); });
291
325
  ws.on('close', (code, reason) => {
292
326
  const was = state.connected;
327
+ if (was) say(closeLine(code, reason));
293
328
  state.connected = false;
294
329
  state.ws = null;
295
330
  for (const c of running.values()) c.abort();
@@ -302,9 +337,10 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
302
337
  return {
303
338
  events: emitter,
304
339
  state,
305
- start() { state.stopped = false; connect(); return this; },
340
+ start() { state.stopped = false; stalls.start(); connect(); return this; },
306
341
  stop() {
307
342
  state.stopped = true;
343
+ stalls.stop();
308
344
  if (state.timer) { clearTimeout(state.timer); state.timer = null; }
309
345
  for (const c of running.values()) c.abort();
310
346
  jobs.killAll();
@@ -315,4 +351,4 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
315
351
  };
316
352
  }
317
353
 
318
- module.exports = { createDaemon, wsUrlFor, systemInfo, authorizeRequestedRoots, VERSION };
354
+ module.exports = { createDaemon, wsUrlFor, systemInfo, authorizeRequestedRoots, callSubject, VERSION };
package/src/stalls.js ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ // A process that is stuck answers no ping, and the server drops the computer as gone.
3
+ // Nothing in the process can say so while it is stuck, so this says it afterwards: how
4
+ // long, and which calls were under way in that time. A computer asleep stops the process
5
+ // the same way; the words leave both open (the desktop app logs the sleep itself).
6
+
7
+ const TICK_MS = 1000;
8
+ const STUCK_MS = 5000;
9
+ const KEEP = 30;
10
+
11
+ function createStallWatch({ say, tickMs = TICK_MS, stuckMs = STUCK_MS, now = Date.now } = {}) {
12
+ const calls = []; // the last KEEP calls, finished or not
13
+ let last = now();
14
+ let timer = null;
15
+
16
+ function started(tool, what) {
17
+ const call = { tool: String(tool), what: String(what || '').replace(/\s+/g, ' ').trim().slice(0, 80), start: now(), end: null };
18
+ calls.push(call);
19
+ if (calls.length > KEEP) calls.shift();
20
+ return () => { call.end = now(); };
21
+ }
22
+
23
+ // A call that finished during the stall counts too: the work that held the process
24
+ // usually is that call, and it is done by the time the timer can run again.
25
+ function during(from, to) {
26
+ return calls
27
+ .filter((c) => c.start <= to && (c.end == null || c.end >= from))
28
+ .map((c) => `${c.tool}${c.what ? ` "${c.what}"` : ''} (${c.end == null ? 'still running' : `took ${Math.round((c.end - c.start) / 1000)} s`})`);
29
+ }
30
+
31
+ function tick() {
32
+ const t = now();
33
+ const gap = t - last;
34
+ last = t;
35
+ if (gap - tickMs < stuckMs) return;
36
+ const what = during(t - gap, t);
37
+ say(`this process did not run for ${Math.round(gap / 1000)} s (busy, or the computer slept)${what.length ? ` - calls in that time: ${what.join('; ')}` : ''}`);
38
+ }
39
+
40
+ return {
41
+ started,
42
+ tick,
43
+ start() {
44
+ if (timer) return;
45
+ last = now();
46
+ timer = setInterval(tick, tickMs);
47
+ if (timer.unref) timer.unref();
48
+ },
49
+ stop() { if (timer) { clearInterval(timer); timer = null; } },
50
+ };
51
+ }
52
+
53
+ module.exports = { createStallWatch, TICK_MS, STUCK_MS };
@@ -114,20 +114,30 @@ function edit(roots, p, edits) {
114
114
  const original = fs.readFileSync(r.abs, 'utf8');
115
115
  if (original.includes('\u0000')) return err(`(cannot edit ${roots.display(r.abs)} — binary content)`);
116
116
  const eol = original.includes('\r\n') ? '\r\n' : '\n';
117
+ // All edits or none: every edit is checked, and every failing one is named, so
118
+ // the model can fix them in one retry and knows nothing was written.
119
+ const problems = [];
120
+ let notFound = false;
117
121
  let next = original;
118
122
  let i = 0;
119
123
  for (const e of edits) {
120
124
  i++;
125
+ const label = edits.length === 1 ? '' : `Edit ${i}: `;
121
126
  const search = String(e && e.search != null ? e.search : '');
122
127
  const replace = String(e && e.replace != null ? e.replace : '');
123
128
  const searchN = eol === '\r\n' ? search.replace(/\r?\n/g, '\r\n') : search;
124
129
  const replaceN = eol === '\r\n' ? replace.replace(/\r?\n/g, '\r\n') : replace;
125
- if (!searchN) return err(`(edit ${i}: empty search text)`);
130
+ if (!searchN) { problems.push(`${label}search text is empty`); continue; }
126
131
  const n = countOccurrences(next, searchN);
127
- if (n === 0) return err(`(edit ${i}: search text not found in ${roots.display(r.abs)} — Read the file and copy the current text exactly)`);
128
- if (n > 1) return err(`(edit ${i}: search text occurs ${n} times in ${roots.display(r.abs)} — include more context so it is unique)`);
132
+ if (n === 0) { problems.push(`${label}search text not found`); notFound = true; continue; }
133
+ if (n > 1) { problems.push(`${label}search text occurs ${n} times — include more context so it is unique`); continue; }
129
134
  next = next.replace(searchN, () => replaceN);
130
135
  }
136
+ if (problems.length) {
137
+ const shown = roots.display(r.abs);
138
+ const head = edits.length === 1 ? `The edit was not applied to ${shown}: ` : `None of the ${edits.length} edits were applied to ${shown}. `;
139
+ return err(`(${head}${problems.join('; ')}.${notFound ? ' Read the file and copy the current text exactly.' : ''})`);
140
+ }
131
141
  fs.writeFileSync(r.abs, next, 'utf8');
132
142
  return ok(`(edited ${roots.display(r.abs)}: ${edits.length} replacement${edits.length === 1 ? '' : 's'})`, { touched: [r.abs] });
133
143
  }
@@ -24,17 +24,32 @@ const WHOLE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\.\.(?:[\\/]|$)|\/)/;
24
24
 
25
25
  // The command as this machine reads it: PowerShell's $env:NAME, cmd's %NAME%, the
26
26
  // POSIX $NAME and ${NAME}, and ~ for the home folder. A name it does not know stays.
27
- function expand(command, { env, home, platform }) {
27
+ // A variable that holds a list of folders (PATH and its kind) stays as written too: a
28
+ // list names no one place, and most commands only add to it - put in, every folder on
29
+ // the list read as named, and a test run was asked about ten folders. Its entries go to
30
+ // `lists`, under the name the command wrote.
31
+ function expand(command, { env, home, platform }, lists = []) {
28
32
  const keys = Object.keys(env);
33
+ const sep = platform === 'win32' ? ';' : ':';
34
+ const absolute = platform === 'win32' ? path.win32.isAbsolute : path.posix.isAbsolute;
29
35
  const get = (name) => {
30
36
  const key = platform === 'win32' ? keys.find((k) => k.toLowerCase() === name.toLowerCase()) : (name in env ? name : null);
31
37
  if (key != null && env[key] != null) return String(env[key]);
32
38
  return /^(home|userprofile)$/i.test(name) ? home : null;
33
39
  };
40
+ const put = (m, name) => {
41
+ const value = get(name);
42
+ if (value == null) return m;
43
+ // A list holds at least two paths of this system ("C:\x" is no list where ":" separates)
44
+ const entries = value.split(sep).filter(Boolean);
45
+ if (entries.filter(absolute).length < 2) return value;
46
+ if (!lists.some((l) => l.name === name)) lists.push({ name, entries });
47
+ return m;
48
+ };
34
49
  return String(command == null ? '' : command)
35
- .replace(/\$\{env:([A-Za-z_][\w()]*)\}|\$env:([A-Za-z_]\w*)/gi, (m, a, b) => get(a || b) ?? m)
36
- .replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => get(n) ?? m)
37
- .replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (m, a, b) => get(a || b) ?? m)
50
+ .replace(/\$\{env:([A-Za-z_][\w()]*)\}|\$env:([A-Za-z_]\w*)/gi, (m, a, b) => put(m, a || b))
51
+ .replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => put(m, n))
52
+ .replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (m, a, b) => put(m, a || b))
38
53
  .replace(/(^|[\s"'=(])~(?=[\\/]|$|[\s"')])/g, (m, pre) => pre + home);
39
54
  }
40
55
 
@@ -78,22 +93,36 @@ function folderOf(word, cwd) {
78
93
 
79
94
  function outsideFolders(roots, command, { cwd, env = process.env, home = os.homedir(), platform = process.platform } = {}) {
80
95
  const shared = roots.okRoots().map((r) => r.real);
96
+ const outside = (folder) => !shared.some((r) => isInside(realExisting(folder) || folder, r));
97
+ const same = (a, b) => isInside(a, b) && isInside(b, a);
81
98
  const found = [];
82
- for (const word of words(expand(command, { env, home, platform }), platform)) {
99
+ const lists = [];
100
+ for (const word of words(expand(command, { env, home, platform }, lists), platform)) {
83
101
  const folder = folderOf(word, cwd || home);
84
- if (!folder) continue;
85
- const real = realExisting(folder) || folder;
86
- if (shared.some((r) => isInside(real, r))) continue;
87
- if (!found.some((f) => isInside(folder, f) && isInside(f, folder))) found.push(folder);
102
+ if (!folder || !outside(folder)) continue;
103
+ if (!found.some((f) => same(folder, f))) found.push(folder);
88
104
  }
89
105
  // A folder inside another one found is covered by it
90
- const folders = found
91
- .filter((f) => !found.some((g) => g !== f && isInside(f, g) && !isInside(g, f)))
92
- .slice(0, MAX_FOLDERS)
93
- // A whole drive, the home folder or what holds it is too much to share in one click
94
- .map((f) => ({ path: f, broad: path.parse(f).root === f || isInside(home, f) }));
106
+ const named = found.filter((f) => !found.some((g) => g !== f && isInside(f, g) && !isInside(g, f)));
107
+ // A list's folders come after the named ones and cover none of them: whatever the
108
+ // server makes of the list, a folder the command names is asked for by itself
109
+ const listed = [];
110
+ for (const { name, entries } of lists) {
111
+ for (const entry of entries) {
112
+ const st = path.isAbsolute(entry) ? statOf(entry) : null;
113
+ if (!st || !st.isDirectory() || !outside(entry)) continue;
114
+ if (![...named, ...listed.map((l) => l.path)].some((f) => same(entry, f))) listed.push({ path: entry, from: name });
115
+ }
116
+ }
117
+ // A whole drive, the home folder or what holds it is too much to share in one click
118
+ const broad = (f) => path.parse(f).root === f || isInside(home, f);
119
+ const folders = [
120
+ ...named.map((f) => ({ path: f, broad: broad(f) })),
121
+ ...listed.map((l) => ({ path: l.path, broad: broad(l.path), from: l.from })),
122
+ ].slice(0, MAX_FOLDERS);
123
+ const said = (f) => (f.from ? `${f.path} (from ${f.from})` : f.path);
95
124
  const text = folders.length
96
- ? `(outside the shared folders: ${folders.map((f) => f.path).join(', ')})`
125
+ ? `(outside the shared folders: ${folders.map(said).join(', ')})`
97
126
  : '(nothing outside the shared folders)';
98
127
  return { ok: true, text, folders };
99
128
  }