wheems 0.0.1 → 0.0.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/CHANGELOG.md CHANGED
@@ -3,6 +3,52 @@
3
3
  Wheems follows [semantic versioning](https://semver.org). While it's 0.0.x, any release may
4
4
  change anything, including commands and config formats, without a migration path.
5
5
 
6
+ ## 0.0.2
7
+
8
+ Fixes from the first preview, a welcome screen, and checks that catch common set-up problems.
9
+ Still an early preview: not ready for real use.
10
+
11
+ **Added**
12
+
13
+ - `wheems` on its own shows a welcome screen: the cloud from the brand kit, in blue, beside the
14
+ name and "Your crew, ready for work.", then how the install is doing (workspaces and agents,
15
+ the service, search, and anything waiting for review). `wheems setup` opens with the cloud too.
16
+ - `wheems init` creates the install when there isn't one, so setup can be scripted from a fresh
17
+ machine (connecting Slack still needs `wheems setup`).
18
+ - `wheems doctor` checks that Claude Code is signed in (`claude auth status`), and that the app is
19
+ a member of each workspace's chat channel: Slack lists public channels the app isn't in, and
20
+ sends no messages from them. The service warns about such a channel when it starts.
21
+
22
+ **Fixed**
23
+
24
+ - Search needs SQLite full-text search (FTS5), which Node only bundles from 22.16 in the 22
25
+ line and from 24; no Node 23 release has it. `engines` is now `^22.16.0 || >=24`, the CLI and
26
+ `wheems service install` refuse other versions with a plain message, `wheems doctor` checks
27
+ that this Node's SQLite has full-text search, and search says so instead of failing with
28
+ "no such module: fts5".
29
+ - `npm run build` now marks `dist/cli/main.js` executable, so a `wheems` linked with `npm link`
30
+ runs instead of failing with "Permission denied".
31
+ - Slack messages with a file attached were silently ignored. Now a file with no words gets a reply
32
+ asking for the text, and with words the agent gets them and a note that files can't be passed
33
+ on yet.
34
+ - After a quick reply from an agent, Wheems could add a false "The agent finished without
35
+ replying here": it recorded when a message was sent only after typing it in.
36
+ - After connecting Slack, setup says to restart the service (which reads tokens only when it
37
+ starts) and how to let someone else talk to the agents.
38
+ - On Linux, `wheems doctor` warns that the service needs macOS for now instead of passing.
39
+ - Docs: where to install Herdr core ([herdr.dev/core](https://herdr.dev/core/)), npm as the way
40
+ to install, inviting the app to a channel, session names, which commands take `--home`, a
41
+ broken table in config.md, and milestone numbers in code comments.
42
+
43
+ **Known issues**
44
+
45
+ - Only one Slack connection, named `slack`, can be set up from `wheems setup` and `wheems init`;
46
+ a second Slack app has to be added by hand.
47
+ - The Slack app's manifest asks for interactivity, which nothing uses until approvals with
48
+ buttons (milestone 3).
49
+ - The launchd label is fixed, so only one install per Mac user can run as a service.
50
+ - `wheems kb search --json` reports inbox notes as `kind: note`; the path tells them apart.
51
+
6
52
  ## 0.0.1
7
53
 
8
54
  The first preview: milestones 0 to 2 of the [roadmap](docs/roadmap.md). Not ready for real use.
package/README.md CHANGED
@@ -78,9 +78,11 @@ Wheems was formerly called Opinr.
78
78
 
79
79
  You need:
80
80
 
81
- - macOS (the service runs under launchd; Linux comes later)
82
- - Node.js 22.13 or later
83
- - Herdr core 0.1.20 or later (`hc`)
81
+ - macOS (the service runs under launchd; Linux, with systemd, comes with the always-on
82
+ milestone)
83
+ - Node.js 22.16 or later in the 22 line, or 24 or later (not 23: its SQLite has no full-text
84
+ search, which Wheems needs)
85
+ - Herdr core 0.1.20 or later (`hc`): see [herdr.dev/core](https://herdr.dev/core/) to install it
84
86
  - A Slack workspace where you can add apps
85
87
 
86
88
  Install the `wheems` command from npm:
@@ -102,9 +104,10 @@ npm link # puts the wheems command on your PATH
102
104
  Clone it anywhere except `~/wheems`. That folder is your install: your private config, notes
103
105
  and knowledge base, kept in a git folder of its own that is never pushed anywhere.
104
106
 
105
- If your default `node` is older than 22.13, use a newer one, such as Homebrew's
107
+ If your default `node` isn't one of those (Homebrew's `node` may be 23), use one that is, such as Homebrew's
106
108
  (`brew install node@22`), and run `/opt/homebrew/opt/node@22/bin/node dist/cli/main.js` in place
107
- of `wheems`. `wheems doctor` says which Node.js it runs on and fails when it's too old.
109
+ of `wheems`. `wheems doctor` says which Node.js it runs on, and fails when it's unsupported or its SQLite has no
110
+ full-text search.
108
111
 
109
112
  ## Set up
110
113
 
@@ -141,6 +144,14 @@ One Slack app serves every workspace, so you do this once.
141
144
  5. Give your Slack member ID (your profile, **⋯**, **Copy member ID**). Only people on this list
142
145
  can talk to your agents. Messages from anyone else are ignored.
143
146
 
147
+ ### Inviting the app to a channel
148
+
149
+ A workspace that chats in a channel needs the app in that channel: in the channel, type
150
+ `/invite` and choose the app. Slack lists public channels the app isn't in, so a channel name
151
+ that's spelt right can still hear nothing; the service warns at start-up and `wheems doctor`
152
+ checks it. The service looks channels up when it starts, so after creating a channel or renaming
153
+ one, run `wheems service restart`.
154
+
144
155
  ### Trusting the workspace folder
145
156
 
146
157
  Claude Code and Codex ask once whether to trust a new folder, and nobody can answer that from
@@ -149,7 +160,9 @@ folder, then press Ctrl+C twice to come back. `wheems doctor` checks the trust.
149
160
 
150
161
  ### Without the menu
151
162
 
152
- Each part has a command of its own, for scripts:
163
+ Each part has a command of its own, for scripts. `wheems init` creates the install too when
164
+ there isn't one, so a fresh machine can start there; connecting Slack still needs `wheems setup`,
165
+ because you type its tokens in yourself:
153
166
 
154
167
  ```bash
155
168
  wheems init personal # a workspace on Claude Code
@@ -226,8 +239,9 @@ can be given skills, but nothing starts them yet: teams of agents come in later
226
239
  Message the Slack app (or the workspace's channel). Each message you send starts a
227
240
  **conversation**, and replies in its thread carry it on.
228
241
 
229
- - Each conversation is its own engine session in Herdr, named after the workspace and agent.
230
- Watch it or take over with `hc attach <session>`.
242
+ - Each conversation is its own engine session in Herdr, named after the workspace with a short
243
+ id for the thread, such as `personal-3f9a2c1b`. Watch it or take over with
244
+ `hc attach <session>`.
231
245
  - A 👀 reaction means your message has reached the agent. It goes when the agent answers.
232
246
  - The agent's last message of each turn is posted to the thread as its answer. The answer is
233
247
  read from the engine's own record of the conversation, never from the screen.
@@ -506,13 +520,15 @@ from its workspace's skills. Pinned skill versions must exist in the library.
506
520
 
507
521
  ## Command reference
508
522
 
509
- Every command takes `--home <dir>` for the install folder (default: `$WHEEMS_HOME`, then
510
- `~/wheems`).
523
+ Commands that use the install take `--home <dir>` for the install folder (default:
524
+ `$WHEEMS_HOME`, then `~/wheems`). `wheems service restart`, `service uninstall` and
525
+ `slack manifest` don't need it.
511
526
 
512
527
  **For you**
513
528
 
514
529
  | Command | What it does |
515
530
  | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
531
+ | `wheems` | The welcome screen: the cloud, and how the install is doing (workspaces, the service, search, review) |
516
532
  | `wheems setup` | The setup menu: engines, Slack, workspaces, knowledge, the service, doctor |
517
533
  | `wheems init <workspace> [--name] [--channel] [--engine] [--model]` | Creates a workspace with its chief of staff. `--engine` is `claude-code` (default), `codex` or `opencode` |
518
534
  | `wheems doctor [--quick] [--json]` | Checks the machine, Herdr core and the install |
@@ -1,12 +1,11 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { resolve } from 'node:path';
4
- import { NODE_MINIMUM_VERSION } from '../../doctor/doctor.js';
4
+ import { nodeVersionProblem } from '../../util/node-support.js';
5
5
  import { defaultHome, paths } from '../../home.js';
6
6
  import { request } from '../../service/control.js';
7
7
  import { installService, plistPath, restartService, serviceStatus, uninstallService, } from '../../service/launchd.js';
8
8
  import { runService } from '../../service/service.js';
9
- import { isAtLeast } from '../../util/version.js';
10
9
  import { packageVersion } from '../version.js';
11
10
  /** The script that is running this CLI, resolved through npm links. */
12
11
  export function cliPath() {
@@ -14,9 +13,9 @@ export function cliPath() {
14
13
  }
15
14
  /** Installs the service under launchd with this Node.js and build, and waits for it to answer. */
16
15
  export async function installAndWait(where) {
17
- if (!isAtLeast(process.versions.node, NODE_MINIMUM_VERSION)) {
18
- throw new Error(`The service needs Node.js ${NODE_MINIMUM_VERSION} or later, and this is ${process.versions.node}.`);
19
- }
16
+ const problem = nodeVersionProblem(process.versions.node);
17
+ if (problem !== null)
18
+ throw new Error(problem);
20
19
  const cli = cliPath();
21
20
  if (!cli.endsWith('.js')) {
22
21
  throw new Error('Install the service from a build of Wheems (npm run build), not from source.');
@@ -156,6 +156,10 @@ async function channels(options, where) {
156
156
  doc.setIn(['connections', connection, 'allowed_users'], doc.createNode(result.allowedUsers, { flow: true }));
157
157
  await writeFile(where.install, doc.toString());
158
158
  ui.success(`${entry.label} is connected. Its tokens are saved privately in ${where.secrets}`);
159
+ ui.info([
160
+ 'The service reads tokens when it starts: restart it (Service) for these to take effect.',
161
+ `To let someone else talk to your agents, add their member ID to allowed_users under connections.${connection} in install.yaml, then restart the service.`,
162
+ ].join('\n'));
159
163
  return true;
160
164
  }
161
165
  /**
package/dist/cli/main.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // Check the Node.js version before loading anything that needs a newer one, so an old
3
- // Node.js gets a plain message rather than a stack trace from a dependency.
4
- const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
5
- if (major < 22 || (major === 22 && minor < 13)) {
6
- process.stderr.write(`Wheems needs Node.js 22.13 or later, and this is ${process.versions.node} (${process.execPath}).\n`);
2
+ // Check the Node.js version before loading anything that needs a newer one, so an
3
+ // unsupported Node.js gets a plain message rather than a stack trace from a dependency.
4
+ import { nodeVersionProblem } from '../util/node-support.js';
5
+ const problem = nodeVersionProblem(process.versions.node);
6
+ if (problem !== null) {
7
+ process.stderr.write(`${problem}\nThis is ${process.execPath}.\n`);
7
8
  process.exit(1);
8
9
  }
9
10
  const { defaultIo, run } = await import('./program.js');
@@ -15,5 +16,4 @@ process.exitCode = await run(process.argv.slice(2), {
15
16
  color,
16
17
  ...defaultIo(),
17
18
  });
18
- export {};
19
19
  //# sourceMappingURL=main.js.map
@@ -15,6 +15,6 @@ export interface Io {
15
15
  export declare function defaultIo(): Pick<Io, 'registry' | 'stdin'>;
16
16
  /** Builds the `wheems` command. */
17
17
  export declare function createProgram(io: Io): Command;
18
- /** Runs the CLI and returns the exit code. */
18
+ /** Runs the CLI and returns the exit code. With no command, it shows the welcome screen. */
19
19
  export declare function run(argv: string[], io: Io): Promise<number>;
20
20
  export declare function formatReport(report: DoctorReport, color: boolean): string;
@@ -15,8 +15,9 @@ import { registerSkill } from './commands/skill.js';
15
15
  import { registerNote } from './commands/note.js';
16
16
  import { registerReply } from './commands/reply.js';
17
17
  import { registerService } from './commands/service.js';
18
- import { setup } from './commands/setup.js';
18
+ import { ensureInstall, setup } from './commands/setup.js';
19
19
  import { clackUi } from './clack-ui.js';
20
+ import { renderWelcome, welcomeStatus } from './welcome.js';
20
21
  import { installAndWait, serviceAnswers } from './commands/service.js';
21
22
  import { runCommand, runInteractive } from '../util/process.js';
22
23
  import { SetupCancelled } from '../contracts/setup.js';
@@ -88,7 +89,7 @@ export function createProgram(io) {
88
89
  });
89
90
  program
90
91
  .command('setup')
91
- .description('Set up Wheems: connect channels, create workspaces, run the service')
92
+ .description('Set up Wheems: engines, chat channels, workspaces, search and the service')
92
93
  .option('--home <dir>', 'the install folder (default: $WHEEMS_HOME or ~/wheems)')
93
94
  .action(async (options) => {
94
95
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -98,6 +99,7 @@ export function createProgram(io) {
98
99
  const where = paths(home, io.env);
99
100
  const registry = io.registry();
100
101
  const ui = clackUi();
102
+ io.out(renderWelcome({ version: packageVersion(), status: [], color: io.color, hints: false }));
101
103
  ui.intro(' Wheems setup ');
102
104
  try {
103
105
  await setup({
@@ -133,7 +135,7 @@ export function createProgram(io) {
133
135
  });
134
136
  program
135
137
  .command('init')
136
- .description('Create a workspace with its chief of staff')
138
+ .description('Create a workspace with its chief of staff (and the install, if there is none)')
137
139
  .argument('<workspace>', 'the workspace id, such as personal')
138
140
  .option('--name <name>', 'its display name (default: from the id)')
139
141
  .option('--channel <channel>', "chat in this channel instead of the bot's direct messages")
@@ -154,8 +156,13 @@ export function createProgram(io) {
154
156
  throw new Error(`No engine "${options.engine}". Choose one of: ${known}`);
155
157
  }
156
158
  const setup = entry.engine;
159
+ const home = resolve(options.home ?? defaultHome(io.env));
160
+ // On a fresh machine, init makes the install too, so setup can be scripted.
161
+ const createdInstall = await ensureInstall(paths(home, io.env));
162
+ if (createdInstall)
163
+ io.out(`Created the install at ${home} (a git folder)\n`);
157
164
  const dir = await initWorkspace({
158
- home: resolve(options.home ?? defaultHome(io.env)),
165
+ home,
159
166
  id,
160
167
  env: io.env,
161
168
  engine: {
@@ -206,8 +213,20 @@ export function createProgram(io) {
206
213
  function shellPath(path) {
207
214
  return /^[\w./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`;
208
215
  }
209
- /** Runs the CLI and returns the exit code. */
216
+ /** Runs the CLI and returns the exit code. With no command, it shows the welcome screen. */
210
217
  export async function run(argv, io) {
218
+ if (argv.length === 0) {
219
+ const home = resolve(defaultHome(io.env));
220
+ // The welcome never fails: a problem reading the install becomes one of its lines.
221
+ const status = await welcomeStatus(home, io.env).catch((error) => [
222
+ {
223
+ mark: '!',
224
+ text: `Couldn't read the install at ${home}: ${error instanceof Error ? error.message : String(error)}. Run wheems doctor.`,
225
+ },
226
+ ]);
227
+ io.out(renderWelcome({ version: packageVersion(), status, color: io.color }));
228
+ return 0;
229
+ }
211
230
  const program = createProgram(io);
212
231
  try {
213
232
  await program.parseAsync(argv, { from: 'user' });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The cloud, from the brand kit's terminal mark (Unicode half blocks, drawn from the mascot's
3
+ * silhouette). Its eye and smile are the gaps the half blocks leave.
4
+ */
5
+ export declare const CLOUD: string[];
6
+ /** The brand line under the name. */
7
+ export declare const TAGLINE = "Your crew, ready for work.";
8
+ export interface WelcomeStatus {
9
+ mark: '✓' | '!' | '-';
10
+ text: string;
11
+ }
12
+ /** The welcome screen: the cloud with the name and tagline beside it, then the install's status. */
13
+ export declare function renderWelcome(options: {
14
+ version: string;
15
+ status: WelcomeStatus[];
16
+ color: boolean;
17
+ /** Ends with the commands to try next. On by default. */
18
+ hints?: boolean;
19
+ }): string;
20
+ /**
21
+ * What the welcome screen says about this install: its workspaces and agents, the service,
22
+ * search, and anything waiting for review. Quick: it reads files and pings the service, and
23
+ * never starts a session.
24
+ */
25
+ export declare function welcomeStatus(home: string, env: NodeJS.ProcessEnv, ping?: (socket: string) => Promise<boolean>): Promise<WelcomeStatus[]>;
@@ -0,0 +1,105 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { loadInstall } from '../config/load.js';
4
+ import { paths } from '../home.js';
5
+ import { listAboutYou } from '../review/review.js';
6
+ import { request } from '../service/control.js';
7
+ import { listProposals } from '../skills/proposals.js';
8
+ /**
9
+ * The cloud, from the brand kit's terminal mark (Unicode half blocks, drawn from the mascot's
10
+ * silhouette). Its eye and smile are the gaps the half blocks leave.
11
+ */
12
+ // Every character is one UTF-16 unit, so .length is the width in columns.
13
+ export const CLOUD = [
14
+ ' ▄▄██▄▄▄',
15
+ ' ██████████',
16
+ ' ▄██▄████████████',
17
+ ' █████████████████',
18
+ ' ▄█████████████████',
19
+ ' ▄████▀█████████████▄▄',
20
+ '▄██████████████████████',
21
+ '▀████▄▀▄████████████████',
22
+ ' ██████████████████████',
23
+ ' ▀▀▀███████████████▀▀',
24
+ ];
25
+ /** The brand line under the name. */
26
+ export const TAGLINE = 'Your crew, ready for work.';
27
+ /**
28
+ * A blue between the brand's cobalt (light backgrounds) and its dark-mode blue, from the
29
+ * 256-colour palette, so the cloud reads on light and dark terminals alike.
30
+ */
31
+ const CLOUD_COLOUR = '\x1b[38;5;69m';
32
+ const BOLD = '\x1b[1m';
33
+ const DIM = '\x1b[2m';
34
+ const RESET = '\x1b[0m';
35
+ /** The welcome screen: the cloud with the name and tagline beside it, then the install's status. */
36
+ export function renderWelcome(options) {
37
+ const paint = (code, text) => (options.color ? `${code}${text}${RESET}` : text);
38
+ const width = Math.max(...CLOUD.map((line) => line.length));
39
+ const beside = {
40
+ 4: paint(BOLD, 'wheems') + ' ' + paint(DIM, options.version),
41
+ 5: TAGLINE,
42
+ };
43
+ const lines = CLOUD.map((line, row) => {
44
+ const text = beside[row];
45
+ const padded = line + ' '.repeat(width - line.length);
46
+ return text === undefined
47
+ ? paint(CLOUD_COLOUR, line)
48
+ : `${paint(CLOUD_COLOUR, padded)} ${text}`;
49
+ });
50
+ const marks = { '✓': '\x1b[32m', '!': '\x1b[33m', '-': DIM };
51
+ return [
52
+ '',
53
+ ...lines,
54
+ '',
55
+ ...options.status.map((s) => `${paint(marks[s.mark], s.mark)} ${s.text}`),
56
+ ...(options.status.length > 0 ? [''] : []),
57
+ ...(options.hints === false
58
+ ? []
59
+ : [paint(DIM, 'wheems setup · wheems doctor · wheems --help'), '']),
60
+ ].join('\n');
61
+ }
62
+ /**
63
+ * What the welcome screen says about this install: its workspaces and agents, the service,
64
+ * search, and anything waiting for review. Quick: it reads files and pings the service, and
65
+ * never starts a session.
66
+ */
67
+ export async function welcomeStatus(home, env, ping = (socket) => request(socket, { op: 'ping' }, 1000).then((answer) => answer.ok, () => false)) {
68
+ const where = paths(home, env);
69
+ const hasInstall = await readFile(join(home, 'install.yaml')).then(() => true, () => false);
70
+ if (!hasInstall) {
71
+ return [{ mark: '!', text: `No install at ${tilde(home, env)} yet: run wheems setup` }];
72
+ }
73
+ const install = await loadInstall(home);
74
+ const workspaces = install.workspaces.filter((w) => w.config);
75
+ const agents = workspaces.reduce((n, w) => n + w.agents.length, 0);
76
+ const status = [];
77
+ status.push(workspaces.length > 0
78
+ ? {
79
+ mark: '✓',
80
+ text: `${count(workspaces.length, 'workspace')}, ${count(agents, 'agent')} in ${tilde(home, env)}`,
81
+ }
82
+ : { mark: '!', text: 'No workspaces yet: run wheems setup' });
83
+ status.push((await ping(where.socket))
84
+ ? { mark: '✓', text: 'The service is running' }
85
+ : { mark: '!', text: "The service isn't running: wheems service install" });
86
+ const embeddings = install.config?.knowledge.embeddings;
87
+ status.push(embeddings
88
+ ? { mark: '✓', text: `Search by meaning with ${embeddings.model ?? embeddings.driver}` }
89
+ : { mark: '-', text: 'Search by keyword only (wheems setup → Knowledge for meaning)' });
90
+ const waiting = (await listProposals(where.state)).length + (await listAboutYou(where.state)).length;
91
+ if (waiting > 0) {
92
+ status.push({ mark: '!', text: `${count(waiting, 'thing')} waiting for you: wheems review` });
93
+ }
94
+ return status;
95
+ }
96
+ function count(n, noun) {
97
+ return `${String(n)} ${noun}${n === 1 ? '' : 's'}`;
98
+ }
99
+ function tilde(path, env) {
100
+ const home = env.HOME;
101
+ return home !== undefined && home !== '' && path.startsWith(`${home}/`)
102
+ ? `~${path.slice(home.length)}`
103
+ : path;
104
+ }
105
+ //# sourceMappingURL=welcome.js.map
@@ -1,7 +1,7 @@
1
1
  import type { Driver, HealthCheck } from './driver.js';
2
2
  /**
3
3
  * The channel contract: sends and receives messages, buttons and threads.
4
- * Version one ships Slack (milestone 1) and Telegram and email (milestone 9).
4
+ * Version one ships Slack (milestone 1) and Telegram and email (milestone 10).
5
5
  *
6
6
  * A channel driver holds one connection, such as one Slack app. Channels inside
7
7
  * it are addressed by the driver's own channel id.
@@ -27,6 +27,16 @@ export interface ChannelDriver extends Driver<'channel'> {
27
27
  react(message: MessageRef, reaction: string, on: boolean): Promise<void>;
28
28
  /** Disconnects. No handler calls happen after it resolves. Safe to call twice. */
29
29
  stop(): Promise<void>;
30
+ /**
31
+ * Optional: a channel's id from its name (or its id), or null when the app can't see it.
32
+ * Drivers without it take channel names as ids.
33
+ */
34
+ resolveChannel?(nameOrId: string): Promise<string | null>;
35
+ /**
36
+ * Optional: whether the app is a member of a channel, so messages there reach it. Null when it
37
+ * can't tell. Seeing a channel isn't enough: Slack lists public channels the app isn't in.
38
+ */
39
+ isMember?(channelId: string): Promise<boolean | null>;
30
40
  }
31
41
  export interface MessageRef {
32
42
  channel: string;
@@ -56,6 +66,10 @@ export interface InboundMessage {
56
66
  /** True for a direct message between the person and the bot. */
57
67
  direct: boolean;
58
68
  receivedAt: Date;
69
+ /** Files attached to it, by name. Wheems can't pass their contents on yet. */
70
+ files?: {
71
+ name: string;
72
+ }[];
59
73
  }
60
74
  export interface ButtonPress {
61
75
  type: 'button';
@@ -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. */
@@ -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 {};
@@ -1,3 +1,4 @@
1
+ import { NODE_SUPPORTED } from './node-support.js';
1
2
  let loaded;
2
3
  /**
3
4
  * Node's built-in SQLite, loaded without its "experimental" warning, which would otherwise
@@ -39,4 +40,38 @@ export async function openDatabase(file, options = {}) {
39
40
  db.exec('PRAGMA journal_mode = WAL');
40
41
  return db;
41
42
  }
43
+ /**
44
+ * Why this SQLite can't do full-text search (FTS5), which search needs, or null when it can.
45
+ * `open` makes a scratch database; by default an in-memory one from `node:sqlite`.
46
+ */
47
+ export async function fullTextSearchProblem(open) {
48
+ let db;
49
+ try {
50
+ db = open ? await open() : new (await sqlite()).DatabaseSync(':memory:');
51
+ }
52
+ catch (error) {
53
+ return `Node's SQLite isn't available: ${describe(error)}. Use ${NODE_SUPPORTED}.`;
54
+ }
55
+ try {
56
+ db.exec('CREATE VIRTUAL TABLE probe USING fts5(text)');
57
+ return null;
58
+ }
59
+ catch (error) {
60
+ return `This Node.js (${process.versions.node}, ${process.execPath}) has SQLite without full-text search (FTS5), which Wheems's search needs (${describe(error)}). Use ${NODE_SUPPORTED}, from nodejs.org or a package that bundles Node's own SQLite.`;
61
+ }
62
+ finally {
63
+ db.close();
64
+ }
65
+ }
66
+ let checked;
67
+ /** Throws a plain explanation when this Node's SQLite has no full-text search. Checks once. */
68
+ export async function requireFullTextSearch() {
69
+ checked ??= fullTextSearchProblem();
70
+ const problem = await checked;
71
+ if (problem !== null)
72
+ throw new Error(problem);
73
+ }
74
+ function describe(error) {
75
+ return error instanceof Error ? error.message : String(error);
76
+ }
42
77
  //# sourceMappingURL=sqlite.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wheems",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "An opinionated framework for running teams of AI agents, on Herdr core",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://wheems.dev",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "type": "module",
26
26
  "engines": {
27
- "node": ">=22.13.0"
27
+ "node": "^22.16.0 || >=24"
28
28
  },
29
29
  "bin": {
30
30
  "wheems": "dist/cli/main.js"
@@ -48,7 +48,7 @@
48
48
  "CHANGELOG.md"
49
49
  ],
50
50
  "scripts": {
51
- "build": "tsc -p tsconfig.build.json",
51
+ "build": "tsc -p tsconfig.build.json && tsx scripts/make-executable.ts",
52
52
  "typecheck": "tsc -p tsconfig.json --noEmit",
53
53
  "lint": "eslint . && prettier --check .",
54
54
  "format": "prettier --write .",