@tensor.chat/companion 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensor.chat/companion",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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
@@ -13,7 +13,9 @@ const bash = require('./tools/bash');
13
13
  const files = require('./tools/files');
14
14
  const { fetchUrl } = require('./tools/fetch');
15
15
  const { extractTouched } = require('./tools/touched');
16
+ const { outsideFolders } = require('./tools/outside');
16
17
  const { Jobs } = require('./tools/jobs');
18
+ const { createStallWatch } = require('./stalls');
17
19
 
18
20
  const VERSION = require('../package.json').version;
19
21
  const RECONNECT_MIN_MS = 1000;
@@ -21,6 +23,17 @@ const RECONNECT_MAX_MS = 60000;
21
23
  const MAX_CONCURRENT_CALLS = 4;
22
24
  const BACKGROUND_FIRST_LOOK_MS = 1500; // a background start still reports the first lines
23
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
+ }
24
37
 
25
38
  function wsUrlFor(serverUrl) {
26
39
  const u = new URL(serverUrl);
@@ -82,7 +95,7 @@ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
82
95
  return { roots, labels: keptLabels, rejected };
83
96
  }
84
97
 
85
- function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell } = {}) {
98
+ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, slowSpawnMs = SLOW_SPAWN_MS } = {}) {
86
99
  if (!serverUrl || !token) throw new Error('serverUrl and token are required');
87
100
  const emitter = new EventEmitter();
88
101
  const say = typeof log === 'function' ? log : () => {};
@@ -92,7 +105,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
92
105
  const running = new Map(); // call id → AbortController
93
106
  const jobs = new Jobs();
94
107
  const unsentExits = []; // job endings the server has not heard yet (offline at the time)
95
- 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 });
96
110
 
97
111
  function send(msg) {
98
112
  if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
@@ -121,7 +135,13 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
121
135
  if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
122
136
  const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
123
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();
124
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`);
125
145
  // A cancelled call (turn aborted, server timeout) takes its foreground job with
126
146
  // it; a background job belongs to the chat, not to the call, and lives on.
127
147
  const onAbort = () => { if (!a.background) jobs.kill(job.id); };
@@ -158,6 +178,12 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
158
178
  case 'write': return files.write(callRoots, a.path, a.content, { overwrite: a.overwrite === true });
159
179
  case 'edit': return files.edit(callRoots, a.path, a.edits);
160
180
  case 'glob': return files.glob(callRoots, a.pattern);
181
+ // The server asks before it judges a shell command: what of it lies outside
182
+ // the shared folders, read with this machine's own variables. Nothing runs.
183
+ case 'outside': {
184
+ const at = a.cwd ? callRoots.resolve(a.cwd) : null;
185
+ return outsideFolders(callRoots, a.command, { cwd: at && !at.error ? at.abs : callRoots.primary() || os.homedir() });
186
+ }
161
187
  case 'grep': return files.grep(callRoots, a.command);
162
188
  case 'ls': return files.ls(callRoots, a.path, { max: a.max });
163
189
  case 'fetchfile': return files.fetchfile(callRoots, a.path, { maxBytes: a.maxBytes });
@@ -189,6 +215,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
189
215
  }
190
216
  const ctrl = new AbortController();
191
217
  running.set(id, ctrl);
218
+ const finished = stalls.started(tool, callSubject(tool, msg.args));
192
219
  emitter.emit('call', { id, tool: msg.tool, args: msg.args });
193
220
  let r;
194
221
  try {
@@ -202,6 +229,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
202
229
  } catch (e) {
203
230
  r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
204
231
  }
232
+ finished();
205
233
  running.delete(id);
206
234
  send({ t: 'result', id, ...r });
207
235
  emitter.emit('result', { id, tool: msg.tool, ok: r.ok });
@@ -241,6 +269,16 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
241
269
  }
242
270
  }
243
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
+
244
282
  function scheduleReconnect() {
245
283
  if (state.stopped || state.timer) return;
246
284
  const wait = state.reconnectMs;
@@ -263,6 +301,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
263
301
  state.ws = ws;
264
302
  ws.on('open', () => {
265
303
  state.connected = true;
304
+ state.connectedAt = Date.now();
305
+ state.lastPingAt = null;
266
306
  state.reconnectMs = RECONNECT_MIN_MS;
267
307
  send(systemInfo(shell, !!localApprovals && !!localShellApproval));
268
308
  flushExits();
@@ -270,6 +310,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
270
310
  emitter.emit('connected');
271
311
  });
272
312
  ws.on('message', onMessage);
313
+ ws.on('ping', () => { state.lastPingAt = Date.now(); });
273
314
  ws.on('unexpected-response', (_req, res) => {
274
315
  const code = res && res.statusCode;
275
316
  say(`server refused the connection (HTTP ${code})`);
@@ -283,6 +324,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
283
324
  ws.on('error', (e) => { say(`socket error: ${e.message}`); });
284
325
  ws.on('close', (code, reason) => {
285
326
  const was = state.connected;
327
+ if (was) say(closeLine(code, reason));
286
328
  state.connected = false;
287
329
  state.ws = null;
288
330
  for (const c of running.values()) c.abort();
@@ -295,9 +337,10 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
295
337
  return {
296
338
  events: emitter,
297
339
  state,
298
- start() { state.stopped = false; connect(); return this; },
340
+ start() { state.stopped = false; stalls.start(); connect(); return this; },
299
341
  stop() {
300
342
  state.stopped = true;
343
+ stalls.stop();
301
344
  if (state.timer) { clearTimeout(state.timer); state.timer = null; }
302
345
  for (const c of running.values()) c.abort();
303
346
  jobs.killAll();
@@ -308,4 +351,4 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
308
351
  };
309
352
  }
310
353
 
311
- 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
  }
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+ // Which folders outside the shared ones does a shell command name? The safety check on
3
+ // the server can only guess what "$env:USERPROFILE\.voicetype" means - this computer
4
+ // knows (03.10.2026: four commands like that left it "not sure"). Every path-looking
5
+ // word of the command, with this machine's variables put in, that names an existing
6
+ // file or folder - or a new file in an existing folder - and lies outside every shared
7
+ // folder counts, as the folder that holds it. A command can reach further than its
8
+ // words (a script reads what it likes): this helps the question, it is no fence.
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { isInside, realExisting } = require('../paths');
14
+
15
+ const MAX_FOLDERS = 10;
16
+ const MAX_WORDS = 2000;
17
+ // What ends a path written without quotes
18
+ const STOP = '\\s"\'<>|*?;,`()';
19
+ const WIN_ABS = new RegExp(`(?<![\\w\\\\/])[A-Za-z]:[\\\\/][^${STOP}]*`, 'g');
20
+ const UNC = new RegExp(`(?<![\\w\\\\])\\\\\\\\[^${STOP}\\\\]+\\\\[^${STOP}]+`, 'g');
21
+ const DOTDOT = new RegExp(`(?<![\\w.\\\\/])\\.\\.(?:[\\\\/][^${STOP}]*)?(?![\\w.])`, 'g');
22
+ const POSIX_ABS = new RegExp(`(?<![\\w.~\\\\/:])/[^${STOP}]+`, 'g');
23
+ const WHOLE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\.\.(?:[\\/]|$)|\/)/;
24
+
25
+ // The command as this machine reads it: PowerShell's $env:NAME, cmd's %NAME%, the
26
+ // POSIX $NAME and ${NAME}, and ~ for the home folder. A name it does not know stays.
27
+ function expand(command, { env, home, platform }) {
28
+ const keys = Object.keys(env);
29
+ const get = (name) => {
30
+ const key = platform === 'win32' ? keys.find((k) => k.toLowerCase() === name.toLowerCase()) : (name in env ? name : null);
31
+ if (key != null && env[key] != null) return String(env[key]);
32
+ return /^(home|userprofile)$/i.test(name) ? home : null;
33
+ };
34
+ 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)
38
+ .replace(/(^|[\s"'=(])~(?=[\\/]|$|[\s"')])/g, (m, pre) => pre + home);
39
+ }
40
+
41
+ // The words that may be paths: a quoted one that starts like a path is one path,
42
+ // spaces and all - cut at a space, "R:\Projekte\Open Chat Pal\x" named a stray file
43
+ // "R:\Projekte\Open". Other quoted text (a -Command "...") and the rest are searched.
44
+ function words(text, platform) {
45
+ const out = [];
46
+ const scan = (s) => {
47
+ for (const re of platform === 'win32' ? [WIN_ABS, UNC, DOTDOT] : [WIN_ABS, UNC, DOTDOT, POSIX_ABS]) {
48
+ re.lastIndex = 0;
49
+ let m;
50
+ while ((m = re.exec(s)) && out.length < MAX_WORDS) out.push(m[0]);
51
+ }
52
+ };
53
+ const rest = text.replace(/"([^"]*)"|'([^']*)'/g, (m, a, b) => {
54
+ const inner = a != null ? a : b;
55
+ if (WHOLE_PATH.test(inner)) out.push(inner); else scan(inner);
56
+ return ' ';
57
+ });
58
+ scan(rest);
59
+ // A full stop or comma after a path belongs to the sentence; ".." stays itself
60
+ return out.map((w) => w.replace(/[,;:\]}]+$/, '').replace(/([^.])\.$/, '$1')).filter(Boolean);
61
+ }
62
+
63
+ function statOf(p) {
64
+ try { return fs.statSync(p); } catch (_) { return null; }
65
+ }
66
+
67
+ // The folder a word stands for: the folder itself, the folder of a file, or the folder
68
+ // a new file would go into. Anything else is not a path on this machine.
69
+ function folderOf(word, cwd) {
70
+ const abs = path.resolve(cwd, word);
71
+ const st = statOf(abs);
72
+ if (st) return st.isDirectory() ? abs : path.dirname(abs);
73
+ if (!/\.[A-Za-z0-9]{1,8}$/.test(path.basename(abs))) return null;
74
+ const parent = path.dirname(abs);
75
+ const ps = statOf(parent);
76
+ return ps && ps.isDirectory() && path.parse(parent).root !== parent ? parent : null;
77
+ }
78
+
79
+ function outsideFolders(roots, command, { cwd, env = process.env, home = os.homedir(), platform = process.platform } = {}) {
80
+ const shared = roots.okRoots().map((r) => r.real);
81
+ const found = [];
82
+ for (const word of words(expand(command, { env, home, platform }), platform)) {
83
+ 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);
88
+ }
89
+ // 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) }));
95
+ const text = folders.length
96
+ ? `(outside the shared folders: ${folders.map((f) => f.path).join(', ')})`
97
+ : '(nothing outside the shared folders)';
98
+ return { ok: true, text, folders };
99
+ }
100
+
101
+ module.exports = { outsideFolders, expand, words };