wheems 0.0.1 → 0.0.3

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.
@@ -2,7 +2,6 @@ export { trustedIn } from '../util/trust.js';
2
2
  import type { HealthCheck } from '../contracts/driver.js';
3
3
  import type { DriverRegistry } from '../contracts/registry.js';
4
4
  export { defaultHome } from '../home.js';
5
- export declare const NODE_MINIMUM_VERSION = "22.13.0";
6
5
  export interface DoctorOptions {
7
6
  /** The install folder. */
8
7
  home: string;
@@ -15,6 +14,8 @@ export interface DoctorOptions {
15
14
  platform?: NodeJS.Platform;
16
15
  /** Whether an engine trusts a folder: true, false, or null when it can't tell. */
17
16
  trust?: (engine: string, dir: string) => Promise<boolean | null>;
17
+ /** Why SQLite can't do full-text search, or null when it can. */
18
+ fullTextSearch?: () => Promise<string | null>;
18
19
  /** Whether the service is installed and answering. */
19
20
  service?: (home: string) => Promise<{
20
21
  installed: boolean;
@@ -13,13 +13,14 @@ import { HERDR_MINIMUM_VERSION } from '../drivers/runtime/herdr/herdr-runtime.js
13
13
  import { KnowledgeSearch } from '../knowledge/search.js';
14
14
  import { listAboutYou } from '../review/review.js';
15
15
  import { listProposals } from '../skills/proposals.js';
16
- import { compareVersions, isAtLeast } from '../util/version.js';
16
+ import { compareVersions } from '../util/version.js';
17
+ import { NODE_SUPPORTED, nodeVersionProblem } from '../util/node-support.js';
18
+ import { fullTextSearchProblem } from '../util/sqlite.js';
17
19
  export { defaultHome } from '../home.js';
18
- export const NODE_MINIMUM_VERSION = '22.13.0';
19
20
  /** Runs every health check: the system, the runtime, and the install's config. */
20
21
  export async function runDoctor(options) {
21
22
  const sections = [];
22
- sections.push({ title: 'System', checks: systemChecks(options) });
23
+ sections.push({ title: 'System', checks: await systemChecks(options) });
23
24
  const installFile = join(options.home, 'install.yaml');
24
25
  const hasInstall = await exists(installFile);
25
26
  const install = hasInstall ? await loadInstall(options.home) : null;
@@ -164,7 +165,11 @@ async function channelChecks(install, options) {
164
165
  }
165
166
  try {
166
167
  const driver = options.registry.create('channel', settings.driver, { ...secret });
167
- checks.push(...(await driver.check()).map((c) => ({ ...c, name: `${connection}: ${c.name}` })));
168
+ const health = await driver.check();
169
+ checks.push(...health.map((c) => ({ ...c, name: `${connection}: ${c.name}` })));
170
+ if (health.every((c) => c.status !== 'fail')) {
171
+ checks.push(...(await channelMembership(install, connection, driver)));
172
+ }
168
173
  }
169
174
  catch (error) {
170
175
  checks.push({
@@ -177,6 +182,46 @@ async function channelChecks(install, options) {
177
182
  }
178
183
  return checks;
179
184
  }
185
+ /**
186
+ * For each workspace that chats in a channel on this connection: can the app see the channel,
187
+ * and is it a member? Slack lists public channels the app isn't in, and sends no messages from
188
+ * them, so a workspace can look bound and never hear anything.
189
+ */
190
+ async function channelMembership(install, connection, driver) {
191
+ const checks = [];
192
+ for (const workspace of install.workspaces) {
193
+ const chat = workspace.config?.channels.chat;
194
+ if (!chat || chat.connection !== connection || !('channel' in chat))
195
+ continue;
196
+ const name = `${workspace.id} chat channel`;
197
+ const id = driver.resolveChannel ? await driver.resolveChannel(chat.channel) : chat.channel;
198
+ if (id === null) {
199
+ checks.push({
200
+ name,
201
+ status: 'fail',
202
+ message: `The app can't see ${chat.channel}`,
203
+ hint: 'Check the name in workspace.yaml, invite the app to the channel, then run wheems service restart.',
204
+ });
205
+ continue;
206
+ }
207
+ const member = driver.isMember ? await driver.isMember(id) : null;
208
+ checks.push(member === true
209
+ ? { name, status: 'pass', message: `The app is in ${chat.channel}` }
210
+ : member === false
211
+ ? {
212
+ name,
213
+ status: 'warn',
214
+ message: `The app isn't in ${chat.channel}, so messages there never reach the chief of staff`,
215
+ hint: `In ${chat.channel}, type /invite and choose the app.`,
216
+ }
217
+ : {
218
+ name,
219
+ status: 'skip',
220
+ message: `Couldn't tell whether the app is in ${chat.channel}`,
221
+ });
222
+ }
223
+ return checks;
224
+ }
180
225
  /** What waits for the person in `wheems review`. Never a failure: it's for their attention. */
181
226
  async function reviewCheck(options) {
182
227
  const state = paths(options.home, options.env).state;
@@ -313,29 +358,36 @@ async function probeService(home) {
313
358
  const answering = await request(paths(home).socket, { op: 'ping' }, 2000).then((answer) => answer.ok, () => false);
314
359
  return { installed: status.loaded, answering };
315
360
  }
316
- function systemChecks(options) {
361
+ async function systemChecks(options) {
317
362
  const nodeVersion = options.nodeVersion ?? process.versions.node;
318
363
  const platform = options.platform ?? process.platform;
319
364
  const checks = [];
320
- checks.push(isAtLeast(nodeVersion, NODE_MINIMUM_VERSION)
321
- ? {
322
- name: 'Node.js',
323
- status: 'pass',
324
- message: `${nodeVersion} meets the minimum ${NODE_MINIMUM_VERSION}`,
325
- }
365
+ const problem = nodeVersionProblem(nodeVersion);
366
+ checks.push(problem === null
367
+ ? { name: 'Node.js', status: 'pass', message: `${nodeVersion} is supported` }
368
+ : { name: 'Node.js', status: 'fail', message: problem, hint: `Install ${NODE_SUPPORTED}.` });
369
+ const search = await (options.fullTextSearch ?? (() => fullTextSearchProblem()))();
370
+ checks.push(search === null
371
+ ? { name: 'SQLite search', status: 'pass', message: "Node's SQLite has full-text search" }
326
372
  : {
327
- name: 'Node.js',
373
+ name: 'SQLite search',
328
374
  status: 'fail',
329
- message: `${nodeVersion} is older than the minimum ${NODE_MINIMUM_VERSION}`,
330
- hint: `Install Node.js ${NODE_MINIMUM_VERSION} or later.`,
331
- });
332
- checks.push(platform === 'darwin' || platform === 'linux'
333
- ? { name: 'platform', status: 'pass', message: platform === 'darwin' ? 'macOS' : 'Linux' }
334
- : {
335
- name: 'platform',
336
- status: 'warn',
337
- message: `${platform} is not supported yet; Wheems runs on macOS and Linux`,
375
+ message: search,
376
+ hint: `Install ${NODE_SUPPORTED}.`,
338
377
  });
378
+ checks.push(platform === 'darwin'
379
+ ? { name: 'platform', status: 'pass', message: 'macOS' }
380
+ : platform === 'linux'
381
+ ? {
382
+ name: 'platform',
383
+ status: 'warn',
384
+ message: 'Linux: the commands work, but the service runs under launchd on macOS only; systemd comes with the always-on milestone',
385
+ }
386
+ : {
387
+ name: 'platform',
388
+ status: 'warn',
389
+ message: `${platform} is not supported yet; Wheems runs on macOS (and Linux, apart from the service)`,
390
+ });
339
391
  return checks;
340
392
  }
341
393
  /** Starts a tiny session, sends it a line, waits, reads it back and removes it. */
@@ -11,3 +11,8 @@ export interface SlackConnectOptions {
11
11
  * its two tokens (checked with Slack), and say who may talk to agents.
12
12
  */
13
13
  export declare function slackConnectFlow(options?: SlackConnectOptions): ConnectFlow;
14
+ /**
15
+ * What's wrong with a pasted token, said so the person can fix it, or undefined if it looks
16
+ * right. It names a token only by its standard prefix, never by any other part of it.
17
+ */
18
+ export declare function tokenProblem(kind: 'bot' | 'app', value: string): string | undefined;
@@ -93,7 +93,8 @@ async function newTokens(ui, context, verify) {
93
93
  copied
94
94
  ? '2. Paste the manifest: it is on your clipboard.'
95
95
  : `2. Paste the manifest from ${manifestFile}`,
96
- '3. Install the app to the workspace, then copy the Bot User OAuth Token (xoxb-…).',
96
+ '3. Install the app to the workspace. Under OAuth & Permissions, copy the',
97
+ ' Bot User OAuth Token (xoxb-…).',
97
98
  '4. Under Basic Information → App-Level Tokens, generate a token with the',
98
99
  ' connections:write scope (xapp-…).',
99
100
  '',
@@ -106,11 +107,11 @@ async function newTokens(ui, context, verify) {
106
107
  for (;;) {
107
108
  const botToken = await ui.secret({
108
109
  message: 'Bot User OAuth Token',
109
- validate: (value) => value.trim().startsWith('xoxb-') ? undefined : 'The bot token starts with xoxb-',
110
+ validate: (value) => tokenProblem('bot', value),
110
111
  });
111
112
  const appToken = await ui.secret({
112
113
  message: 'App-Level Token',
113
- validate: (value) => value.trim().startsWith('xapp-') ? undefined : 'The app-level token starts with xapp-',
114
+ validate: (value) => tokenProblem('app', value),
114
115
  });
115
116
  try {
116
117
  await ui.task('Checking the tokens with Slack', () => verify(botToken.trim(), appToken.trim()), (id) => `Connected as ${id.botUser} in ${id.team}`);
@@ -123,4 +124,53 @@ async function newTokens(ui, context, verify) {
123
124
  }
124
125
  }
125
126
  }
127
+ /** What each token is called in Slack, where to find it, and how it starts. */
128
+ const TOKENS = {
129
+ bot: {
130
+ name: 'bot token',
131
+ prefix: 'xoxb-',
132
+ where: 'Under OAuth & Permissions, copy the Bot User OAuth Token',
133
+ },
134
+ app: {
135
+ name: 'app-level token',
136
+ prefix: 'xapp-',
137
+ where: 'Under Basic Information → App-Level Tokens, copy the token',
138
+ },
139
+ };
140
+ /** Other Slack tokens people paste by mistake, by their standard prefix. */
141
+ const OTHER_TOKENS = {
142
+ 'xoxp-': 'a user token',
143
+ 'xoxe-': 'a refresh token',
144
+ 'xoxa-': 'a workspace token',
145
+ };
146
+ const CLEAR = 'Clear the field with Backspace, then paste it once.';
147
+ /**
148
+ * What's wrong with a pasted token, said so the person can fix it, or undefined if it looks
149
+ * right. It names a token only by its standard prefix, never by any other part of it.
150
+ */
151
+ export function tokenProblem(kind, value) {
152
+ const want = TOKENS[kind];
153
+ const other = TOKENS[kind === 'bot' ? 'app' : 'bot'];
154
+ const token = value.trim();
155
+ const fix = `${want.where} (${want.prefix}…).`;
156
+ if (token === '')
157
+ return `Paste the ${want.name}. ${fix}`;
158
+ if (token.split(want.prefix).length > 2) {
159
+ return `That looks like more than one paste. ${CLEAR}`;
160
+ }
161
+ if (token.startsWith(want.prefix)) {
162
+ return /\s/.test(token) ? `There's a space or line break in it. ${CLEAR}` : undefined;
163
+ }
164
+ if (token.includes(want.prefix)) {
165
+ return `There's something before the ${want.prefix} at the start. ${CLEAR}`;
166
+ }
167
+ if (token.startsWith(other.prefix)) {
168
+ return `That starts with ${other.prefix}, so it's the ${other.name}. ${fix}`;
169
+ }
170
+ const known = Object.entries(OTHER_TOKENS).find(([prefix]) => token.startsWith(prefix));
171
+ if (known !== undefined) {
172
+ return `That starts with ${known[0]}, so it's ${known[1]}, not the ${want.name}. ${fix}`;
173
+ }
174
+ return `That isn't the ${want.name}: it starts with ${want.prefix}. ${fix}`;
175
+ }
126
176
  //# sourceMappingURL=connect.js.map
@@ -24,6 +24,7 @@ export declare class SlackChannel implements ChannelDriver {
24
24
  * Returns null when the bot cannot see a channel by that name.
25
25
  */
26
26
  resolveChannel(nameOrId: string): Promise<string | null>;
27
+ isMember(channelId: string): Promise<boolean | null>;
27
28
  stop(): Promise<void>;
28
29
  private onEvent;
29
30
  private toDriverError;
@@ -139,6 +139,16 @@ export class SlackChannel {
139
139
  } while (cursor !== undefined && !this.channelIds.has(name));
140
140
  return this.channelIds.get(name) ?? null;
141
141
  }
142
+ async isMember(channelId) {
143
+ try {
144
+ const info = await this.transport.call('conversations.info', { channel: channelId });
145
+ const member = info.channel?.is_member;
146
+ return typeof member === 'boolean' ? member : null;
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
142
152
  async stop() {
143
153
  this.handler = undefined;
144
154
  if (!this.started)
@@ -150,8 +160,9 @@ export class SlackChannel {
150
160
  const handler = this.handler;
151
161
  if (!handler || event.type !== 'message')
152
162
  return;
153
- // Edits, deletions, joins and bot posts arrive as subtypes; only plain messages count.
154
- if (typeof event.subtype === 'string')
163
+ // Edits, deletions, joins and bot posts arrive as subtypes; only plain messages, and
164
+ // messages with files attached, count.
165
+ if (typeof event.subtype === 'string' && event.subtype !== 'file_share')
155
166
  return;
156
167
  if (typeof event.bot_id === 'string' || event.user === this.botUserId)
157
168
  return;
@@ -170,6 +181,8 @@ export class SlackChannel {
170
181
  text: typeof event.text === 'string' ? event.text : '',
171
182
  direct: event.channel_type === 'im',
172
183
  receivedAt: new Date(Number.parseFloat(event.ts) * 1000),
184
+ ...(Array.isArray(event.files) &&
185
+ event.files.length > 0 && { files: fileNames(event.files) }),
173
186
  });
174
187
  }
175
188
  toDriverError(error, what) {
@@ -209,4 +222,12 @@ function chunks(text, size) {
209
222
  parts.push(rest);
210
223
  return parts;
211
224
  }
225
+ /** The names of the files on a Slack message. */
226
+ function fileNames(files) {
227
+ return files.map((file) => {
228
+ const f = (file ?? {});
229
+ const name = typeof f.name === 'string' ? f.name : typeof f.title === 'string' ? f.title : 'a file';
230
+ return { name };
231
+ });
232
+ }
212
233
  //# sourceMappingURL=slack-channel.js.map
@@ -28,6 +28,8 @@ export declare class ClaudeCodeEngine implements EngineDriver {
28
28
  }, after: string | null): Promise<FinalMessage[]>;
29
29
  private transcript;
30
30
  check(): Promise<HealthCheck[]>;
31
+ /** Whether Claude Code is signed in, from `claude auth status`. Never shows the account. */
32
+ private signInCheck;
31
33
  prepare(agent: AgentDefinition, context: EngineContext): Promise<LaunchPlan>;
32
34
  }
33
35
  /**
@@ -82,7 +82,49 @@ export class ClaudeCodeEngine {
82
82
  ];
83
83
  }
84
84
  const version = extractVersion(result.stdout) ?? result.stdout.trim();
85
- return [{ name: 'Claude Code', status: 'pass', message: `claude ${version}` }];
85
+ return [
86
+ { name: 'Claude Code', status: 'pass', message: `claude ${version}` },
87
+ await this.signInCheck(),
88
+ ];
89
+ }
90
+ /** Whether Claude Code is signed in, from `claude auth status`. Never shows the account. */
91
+ async signInCheck() {
92
+ const status = await runCommand(this.binary, ['auth', 'status', '--json'], {
93
+ timeoutMs: 15_000,
94
+ });
95
+ let loggedIn = null;
96
+ let method = null;
97
+ try {
98
+ const parsed = JSON.parse(status.stdout);
99
+ if (typeof parsed.loggedIn === 'boolean')
100
+ loggedIn = parsed.loggedIn;
101
+ if (typeof parsed.authMethod === 'string')
102
+ method = parsed.authMethod;
103
+ }
104
+ catch {
105
+ // An older Claude Code without `auth status`, or output it doesn't recognise.
106
+ }
107
+ if (loggedIn === true) {
108
+ return {
109
+ name: 'Claude Code sign-in',
110
+ status: 'pass',
111
+ message: method !== null ? `Signed in (${method})` : 'Signed in',
112
+ };
113
+ }
114
+ if (loggedIn === false) {
115
+ return {
116
+ name: 'Claude Code sign-in',
117
+ status: 'fail',
118
+ message: 'Claude Code is not signed in, so agents on it cannot answer',
119
+ hint: 'Run claude auth login, or wheems setup and choose Engines.',
120
+ };
121
+ }
122
+ return {
123
+ name: 'Claude Code sign-in',
124
+ status: 'warn',
125
+ message: "Couldn't tell whether Claude Code is signed in (claude auth status didn't say)",
126
+ hint: 'Update Claude Code, or check by running claude yourself.',
127
+ };
86
128
  }
87
129
  prepare(agent, context) {
88
130
  const { session } = context;
@@ -205,7 +205,7 @@ export class Hc {
205
205
  }
206
206
  function toHcError(error, stderr, binary) {
207
207
  if (error.code === 'ENOENT') {
208
- return new HcError('missing', `Herdr core's hc command was not found (${binary})`, 'Install Herdr core, or make sure hc is on PATH.', null, { cause: error });
208
+ return new HcError('missing', `Herdr core's hc command was not found (${binary})`, 'Install Herdr core (https://herdr.dev/core/), or make sure hc is on PATH.', null, { cause: error });
209
209
  }
210
210
  const exitCode = typeof error.code === 'number' ? error.code : null;
211
211
  if (error.killed === true) {
@@ -33,7 +33,7 @@ export class HerdrRuntime {
33
33
  name: 'hc installed',
34
34
  status: 'fail',
35
35
  message: describe(error),
36
- hint: 'Install Herdr core and make sure hc is on PATH.',
36
+ hint: 'Install Herdr core (https://herdr.dev/core/) and make sure hc is on PATH.',
37
37
  });
38
38
  return checks;
39
39
  }
@@ -2,6 +2,7 @@ import { mkdir, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { DriverError } from '../../contracts/driver.js';
4
4
  import { COLLECTION_NAME, } from '../../contracts/vector-store.js';
5
+ import { NODE_SUPPORTED } from '../../util/node-support.js';
5
6
  import { openDatabase, sqlite } from '../../util/sqlite.js';
6
7
  /**
7
8
  * Vectors in SQLite files, one per collection, compared in code. Every search reads every
@@ -37,7 +38,7 @@ export class SqliteVectorStore {
37
38
  name: 'vector store',
38
39
  status: 'fail',
39
40
  message: `Node's SQLite isn't available: ${error instanceof Error ? error.message : String(error)}`,
40
- hint: 'Use Node.js 22.13 or later.',
41
+ hint: `Use ${NODE_SUPPORTED}.`,
41
42
  },
42
43
  ];
43
44
  }
@@ -73,6 +73,11 @@ export interface SearchIndexOptions {
73
73
  /** Where vectors go; needed only with embeddings. */
74
74
  vectors: VectorStoreDriver | null;
75
75
  log?: (message: string) => void;
76
+ /**
77
+ * Throws when SQLite can't do full-text search, which the index needs. Defaults to checking
78
+ * this Node's `node:sqlite`; tests pass their own.
79
+ */
80
+ requireFullTextSearch?: () => Promise<void>;
76
81
  }
77
82
  /** One workspace's search index. Writes run one at a time; searches can run alongside. */
78
83
  export declare class SearchIndex {
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { basename, join, relative, sep } from 'node:path';
4
4
  import { parseDocument } from 'yaml';
5
- import { openDatabase } from '../util/sqlite.js';
5
+ import { openDatabase, requireFullTextSearch } from '../util/sqlite.js';
6
6
  import { knowledgePaths, looksLikeSecret, noteTitle } from './notes.js';
7
7
  /**
8
8
  * Searching a workspace's knowledge base: its pages and notes, by keyword with SQLite's
@@ -328,6 +328,7 @@ export class SearchIndex {
328
328
  if (!(await exists(this.dbFile(current.generation)))) {
329
329
  throw new Error(`The ${this.workspace} workspace's search index is missing. The service rebuilds it, or run: wheems kb reindex`);
330
330
  }
331
+ await (this.options.requireFullTextSearch ?? requireFullTextSearch)();
331
332
  const db = await openDatabase(this.dbFile(current.generation));
332
333
  createSchema(db);
333
334
  const collection = current.embeddings !== null && this.options.vectors
@@ -351,6 +352,7 @@ export class SearchIndex {
351
352
  const vectors = embeddings !== null ? this.options.vectors : null;
352
353
  if (vectors)
353
354
  await vectors.drop(this.collectionName(generation));
355
+ await (this.options.requireFullTextSearch ?? requireFullTextSearch)();
354
356
  const db = await openDatabase(file);
355
357
  createSchema(db);
356
358
  const connection = {
package/dist/secrets.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Credentials, by name: `secrets.slack.bot_token`. They live in a private file on
3
3
  * this machine (mode 600), outside the install folder, and never pass through an
4
- * agent. The encrypted secret store comes in milestone 8.
4
+ * agent. The vault (the Keychain, or an encrypted file on servers) comes in milestone 9.
5
5
  */
6
6
  export type Secrets = Record<string, Record<string, string>>;
7
7
  export declare function readSecrets(file: string): Promise<Secrets>;
@@ -58,7 +58,21 @@ export class ConversationManager {
58
58
  if (!binding)
59
59
  return Promise.resolve();
60
60
  const key = conversationKey(binding.workspace.id, connection, event.channel, replyThread(event));
61
- return this.enqueue(key, () => this.deliver(key, binding, event).catch((error) => {
61
+ let message = event;
62
+ // Files can't reach the agent yet. Say so, rather than leave them unanswered.
63
+ if (event.files !== undefined && event.files.length > 0) {
64
+ const names = event.files.map((f) => f.name).join(', ');
65
+ if (event.text.trim() === '') {
66
+ return this.enqueue(key, () => this.notice(key, binding, replyThread(event), `I can't open files yet (${names}). Paste the text, or say in words what you need.`).catch((error) => {
67
+ this.options.log(`Conversation ${key}: ${describe(error)}`);
68
+ }));
69
+ }
70
+ message = {
71
+ ...event,
72
+ text: `${event.text}\n\n(The person attached ${event.files.length === 1 ? 'a file' : `${String(event.files.length)} files`}: ${names}. Wheems can't pass files to you yet, so if you need what's in them, ask the person to paste it.)`,
73
+ };
74
+ }
75
+ return this.enqueue(key, () => this.deliver(key, binding, message).catch((error) => {
62
76
  this.options.log(`Conversation ${key}: ${describe(error)}`);
63
77
  return this.notice(key, binding, replyThread(event), `Something went wrong: ${describe(error)}`);
64
78
  }));
@@ -230,10 +244,12 @@ export class ConversationManager {
230
244
  if (wait > 0)
231
245
  await sleep(wait);
232
246
  }
247
+ // Taken before sending: a quick agent can reply before the turn is being followed.
248
+ const sentAt = Date.now();
233
249
  const sent = await this.options.runtime.send(session.id, event.text);
234
250
  await this.options.store.touch(key, now);
235
251
  await this.options.store.addPending(key);
236
- this.watchTurn(key, binding, sessionName, sent.turn, seen, this.conversationFor(key, binding));
252
+ this.watchTurn(key, binding, sessionName, sent.turn, seen, this.conversationFor(key, binding), false, sentAt);
237
253
  }
238
254
  catch (error) {
239
255
  if (!isDriverError(error, 'blocked'))
@@ -277,7 +293,8 @@ export class ConversationManager {
277
293
  pending: 1,
278
294
  });
279
295
  // A new session's turns count from 0; its first turn is the message it was started with.
280
- this.watchTurn(key, binding, sessionName, 0, seen, this.conversationFor(key, binding));
296
+ // `now` was taken before the session started, so a reply to its first message counts.
297
+ this.watchTurn(key, binding, sessionName, 0, seen, this.conversationFor(key, binding), false, now.getTime());
281
298
  this.options.log(`Started ${started.id} (${sessionName}) for ${key}`);
282
299
  this.trimLater(binding.workspace.id, sessionName);
283
300
  }
@@ -379,7 +396,9 @@ export class ConversationManager {
379
396
  }
380
397
  }
381
398
  /** Follows the turn a message started, and tells the person if it ends badly or silently. */
382
- watchTurn(key, binding, sessionName, fromTurn, seen, target, recovered = false) {
399
+ watchTurn(key, binding, sessionName, fromTurn, seen, target, recovered = false,
400
+ /** When the message was handed to the agent; replies from then on count as its answer. */
401
+ sentAt = Date.now()) {
383
402
  const active = this.watches.get(key);
384
403
  if (active && active.sessionName === sessionName && !active.closed) {
385
404
  // A turn is already being followed. The agent takes this message during that turn or
@@ -391,7 +410,7 @@ export class ConversationManager {
391
410
  const watch = {
392
411
  sessionName,
393
412
  fromTurn,
394
- sentAt: Date.now(),
413
+ sentAt,
395
414
  seen: seen ? [seen] : [],
396
415
  queued: [],
397
416
  closed: false,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Runs the service under launchd on macOS, as a user agent that starts at login
3
- * and restarts if it stops. Linux (systemd) arrives with milestone 9.
3
+ * and restarts if it stops. Linux (systemd) arrives with the always-on milestone (10).
4
4
  */
5
5
  import { mkdir, rm, writeFile } from 'node:fs/promises';
6
6
  import { homedir, userInfo } from 'node:os';
@@ -99,7 +99,7 @@ export function parseLaunchctlPrint(text) {
99
99
  }
100
100
  function ensureMac() {
101
101
  if (process.platform !== 'darwin') {
102
- throw new Error('The service runs under launchd on macOS for now; systemd on Linux comes in milestone 9.');
102
+ throw new Error('The service runs under launchd on macOS for now; systemd on Linux comes with the always-on milestone (10).');
103
103
  }
104
104
  }
105
105
  function xml(value) {
@@ -65,12 +65,17 @@ export async function runService(options) {
65
65
  const channel = channelFor(chat.connection);
66
66
  let channelId = null;
67
67
  if ('channel' in chat) {
68
- const resolver = channel.resolveChannel;
69
- channelId = resolver ? await resolver.call(channel, chat.channel) : chat.channel;
68
+ channelId = channel.resolveChannel
69
+ ? await channel.resolveChannel(chat.channel)
70
+ : chat.channel;
70
71
  if (channelId === null) {
71
- log(`Workspace ${workspace.id}: the bot cannot see channel ${chat.channel}; invite it to the channel`);
72
+ log(`Workspace ${workspace.id}: the app can't see channel ${chat.channel}; check the name, invite the app to it, then run wheems service restart`);
72
73
  continue;
73
74
  }
75
+ // Slack lists public channels the app isn't in, and sends no messages from them.
76
+ if ((await channel.isMember?.(channelId)) === false) {
77
+ log(`Workspace ${workspace.id}: the app isn't in channel ${chat.channel}, so messages there won't reach the chief of staff. Invite it: in that channel, /invite @ the app's name`);
78
+ }
74
79
  }
75
80
  bindings.push({ workspace, connection: chat.connection, channel: channelId });
76
81
  log(`Workspace ${workspace.id}: chat on ${chat.connection} ${channelId === null ? '(direct messages)' : channelId}`);
@@ -29,6 +29,9 @@ export declare class MemoryChannel implements ChannelDriver {
29
29
  threadId?: string;
30
30
  userId?: string;
31
31
  direct?: boolean;
32
+ files?: {
33
+ name: string;
34
+ }[];
32
35
  }): Promise<string>;
33
36
  /** Delivers a press of a button on a message the driver sent. */
34
37
  press(press: {
@@ -77,6 +77,7 @@ export class MemoryChannel {
77
77
  text: message.text,
78
78
  direct: message.direct ?? false,
79
79
  receivedAt: new Date(),
80
+ ...(message.files !== undefined && { files: message.files }),
80
81
  });
81
82
  return messageId;
82
83
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Which Node.js versions Wheems runs on. Search needs SQLite's full-text search (FTS5) in
3
+ * Node's built-in `node:sqlite`. Node bundles SQLite with FTS5 from 22.16.0 and 24.0.0 (nodejs
4
+ * PR 57621); no Node 23 release has it, and below 22.13 `node:sqlite` needs a flag. Builds that
5
+ * use the system's SQLite can still lack it, so doctor and search also check at run time.
6
+ *
7
+ * Kept free of imports: the CLI checks this before loading anything else.
8
+ */
9
+ export declare const NODE_SUPPORTED = "Node.js 22.16 or later in the 22 line, or 24 or later";
10
+ /** Why this Node.js version can't run Wheems, or null when it can. */
11
+ export declare function nodeVersionProblem(version: string): string | null;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Which Node.js versions Wheems runs on. Search needs SQLite's full-text search (FTS5) in
3
+ * Node's built-in `node:sqlite`. Node bundles SQLite with FTS5 from 22.16.0 and 24.0.0 (nodejs
4
+ * PR 57621); no Node 23 release has it, and below 22.13 `node:sqlite` needs a flag. Builds that
5
+ * use the system's SQLite can still lack it, so doctor and search also check at run time.
6
+ *
7
+ * Kept free of imports: the CLI checks this before loading anything else.
8
+ */
9
+ export const NODE_SUPPORTED = 'Node.js 22.16 or later in the 22 line, or 24 or later';
10
+ /** Why this Node.js version can't run Wheems, or null when it can. */
11
+ export function nodeVersionProblem(version) {
12
+ const [major = 0, minor = 0] = version.replace(/^v/, '').split('.').map(Number);
13
+ if (major === 23) {
14
+ return `Node.js ${version} can't run Wheems: no Node 23 release has SQLite full-text search, which Wheems's search needs. Use ${NODE_SUPPORTED}.`;
15
+ }
16
+ if (major < 22 || (major === 22 && minor < 16)) {
17
+ return `Node.js ${version} is too old for Wheems. Use ${NODE_SUPPORTED}.`;
18
+ }
19
+ return null;
20
+ }
21
+ //# sourceMappingURL=node-support.js.map
@@ -10,4 +10,16 @@ export declare function sqlite(): Promise<Sqlite>;
10
10
  export declare function openDatabase(file: string, options?: {
11
11
  readOnly?: boolean;
12
12
  }): Promise<DatabaseSync>;
13
+ /** Something that runs SQL, as `DatabaseSync` does; tests pass a stand-in. */
14
+ export interface SqlRunner {
15
+ exec(sql: string): void;
16
+ close(): void;
17
+ }
18
+ /**
19
+ * Why this SQLite can't do full-text search (FTS5), which search needs, or null when it can.
20
+ * `open` makes a scratch database; by default an in-memory one from `node:sqlite`.
21
+ */
22
+ export declare function fullTextSearchProblem(open?: () => Promise<SqlRunner>): Promise<string | null>;
23
+ /** Throws a plain explanation when this Node's SQLite has no full-text search. Checks once. */
24
+ export declare function requireFullTextSearch(): Promise<void>;
13
25
  export {};