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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,74 @@
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.3
7
+
8
+ A new look for the cloud in the terminal, and clearer help when a Slack token is wrong. Still an
9
+ early preview: not ready for real use. The first release published from GitHub.
10
+
11
+ **Changed**
12
+
13
+ - The cloud on the welcome screen and in `wheems setup` is now the mascot's outline, with its eye
14
+ and smile, instead of a solid shape whose face got lost. It's drawn in the terminal's text colour
15
+ (white on a dark terminal, near-black on a light one) rather than blue. It's left out when the
16
+ output isn't a terminal (piped or saved to a file) and when the terminal is narrower than 53
17
+ columns, so only the name, tagline and status are shown.
18
+ - The cloud winks once when it appears, in an interactive terminal. Set `WHEEMS_NO_ANIMATION` to
19
+ stop it; it never winks in CI, in a dumb terminal, or when the output isn't a terminal.
20
+
21
+ **Fixed**
22
+
23
+ - When a pasted Slack token isn't right, setup says why instead of only "The bot token starts with
24
+ xoxb-": the app-level token pasted for the bot token (or the other way round), a user or refresh
25
+ token, two pastes run together, something in front of the token, or a space in it, each with
26
+ where in Slack to find the right one. It names tokens only by their standard prefix.
27
+
28
+ ## 0.0.2
29
+
30
+ Fixes from the first preview, a welcome screen, and checks that catch common set-up problems.
31
+ Still an early preview: not ready for real use.
32
+
33
+ **Added**
34
+
35
+ - `wheems` on its own shows a welcome screen: the cloud from the brand kit, in blue, beside the
36
+ name and "Your crew, ready for work.", then how the install is doing (workspaces and agents,
37
+ the service, search, and anything waiting for review). `wheems setup` opens with the cloud too.
38
+ - `wheems init` creates the install when there isn't one, so setup can be scripted from a fresh
39
+ machine (connecting Slack still needs `wheems setup`).
40
+ - `wheems doctor` checks that Claude Code is signed in (`claude auth status`), and that the app is
41
+ a member of each workspace's chat channel: Slack lists public channels the app isn't in, and
42
+ sends no messages from them. The service warns about such a channel when it starts.
43
+
44
+ **Fixed**
45
+
46
+ - Search needs SQLite full-text search (FTS5), which Node only bundles from 22.16 in the 22
47
+ line and from 24; no Node 23 release has it. `engines` is now `^22.16.0 || >=24`, the CLI and
48
+ `wheems service install` refuse other versions with a plain message, `wheems doctor` checks
49
+ that this Node's SQLite has full-text search, and search says so instead of failing with
50
+ "no such module: fts5".
51
+ - `npm run build` now marks `dist/cli/main.js` executable, so a `wheems` linked with `npm link`
52
+ runs instead of failing with "Permission denied".
53
+ - Slack messages with a file attached were silently ignored. Now a file with no words gets a reply
54
+ asking for the text, and with words the agent gets them and a note that files can't be passed
55
+ on yet.
56
+ - After a quick reply from an agent, Wheems could add a false "The agent finished without
57
+ replying here": it recorded when a message was sent only after typing it in.
58
+ - After connecting Slack, setup says to restart the service (which reads tokens only when it
59
+ starts) and how to let someone else talk to the agents.
60
+ - On Linux, `wheems doctor` warns that the service needs macOS for now instead of passing.
61
+ - Docs: where to install Herdr core ([herdr.dev/core](https://herdr.dev/core/)), npm as the way
62
+ to install, inviting the app to a channel, session names, which commands take `--home`, a
63
+ broken table in config.md, and milestone numbers in code comments.
64
+
65
+ **Known issues**
66
+
67
+ - Only one Slack connection, named `slack`, can be set up from `wheems setup` and `wheems init`;
68
+ a second Slack app has to be added by hand.
69
+ - The Slack app's manifest asks for interactivity, which nothing uses until approvals with
70
+ buttons (milestone 3).
71
+ - The launchd label is fixed, so only one install per Mac user can run as a service.
72
+ - `wheems kb search --json` reports inbox notes as `kind: note`; the path tells them apart.
73
+
6
74
  ## 0.0.1
7
75
 
8
76
  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 |
@@ -649,6 +665,7 @@ skills:
649
665
  | `WHEEMS_SECRETS` | The secrets file (default `~/.config/wheems/secrets.json`, or under `$XDG_CONFIG_HOME`) |
650
666
  | `WHEEMS_WORKSPACE` | The workspace `wheems kb search` searches. Wheems sets it in every agent session |
651
667
  | `WHEEMS_CONVERSATION` | The conversation an agent's commands belong to. Wheems sets it in every agent session |
668
+ | `WHEEMS_NO_ANIMATION` | Set to anything to stop the cloud winking on the welcome screen and in `wheems setup` |
652
669
 
653
670
  ### Config the schemas accept but nothing uses yet
654
671
 
@@ -782,6 +799,23 @@ also run it against a fake `hc`, which is what CI uses. Set `WHEEMS_TEST_OLLAMA_
782
799
  | `examples/install/` | A complete example install |
783
800
  | `docs/` | Design documents |
784
801
 
802
+ ### Releasing
803
+
804
+ GitHub publishes each release. To release, merge a pull request to `main` that:
805
+
806
+ 1. changes `version` in `package.json` (and `package-lock.json`);
807
+ 2. adds a `## <version>` section to `CHANGELOG.md`.
808
+
809
+ The [release workflow](.github/workflows/release.yml) then runs the full check on Linux and
810
+ macOS and publishes to npm. It then tags the commit `v<version>` and creates a GitHub release
811
+ with that changelog section as its notes. A push to `main` whose version is already published and
812
+ tagged does nothing.
813
+
814
+ npm trusts the workflow through
815
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers), so no npm token is stored
816
+ anywhere. The package's settings on npmjs.com name this repository and `release.yml` as its
817
+ trusted publisher.
818
+
785
819
  ## More documentation
786
820
 
787
821
  The documents in [`docs/`](docs/) go deeper, and some describe the design of milestones not built
@@ -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');
@@ -13,7 +14,7 @@ process.exitCode = await run(process.argv.slice(2), {
13
14
  err: (text) => process.stderr.write(text),
14
15
  env: process.env,
15
16
  color,
17
+ columns: process.stdout.isTTY ? process.stdout.columns : undefined,
16
18
  ...defaultIo(),
17
19
  });
18
- export {};
19
20
  //# sourceMappingURL=main.js.map
@@ -8,6 +8,8 @@ export interface Io {
8
8
  err: (text: string) => void;
9
9
  env: NodeJS.ProcessEnv;
10
10
  color: boolean;
11
+ /** The terminal's width, or undefined when the output isn't a terminal. */
12
+ columns?: number | undefined;
11
13
  registry: () => DriverRegistry;
12
14
  stdin: () => Promise<string>;
13
15
  }
@@ -15,6 +17,6 @@ export interface Io {
15
17
  export declare function defaultIo(): Pick<Io, 'registry' | 'stdin'>;
16
18
  /** Builds the `wheems` command. */
17
19
  export declare function createProgram(io: Io): Command;
18
- /** Runs the CLI and returns the exit code. */
20
+ /** Runs the CLI and returns the exit code. With no command, it shows the welcome screen. */
19
21
  export declare function run(argv: string[], io: Io): Promise<number>;
20
22
  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 { canWink, renderWelcomeHead, renderWelcomeTail, SHOW_CURSOR, showCloud, welcomeStatus, wink, } 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,13 @@ 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(renderWelcomeHead({
103
+ version: packageVersion(),
104
+ color: io.color,
105
+ cloud: showCloud(io.columns),
106
+ }));
107
+ if (canWink(io.columns, io.env))
108
+ await winkOnce(io);
101
109
  ui.intro(' Wheems setup ');
102
110
  try {
103
111
  await setup({
@@ -133,7 +141,7 @@ export function createProgram(io) {
133
141
  });
134
142
  program
135
143
  .command('init')
136
- .description('Create a workspace with its chief of staff')
144
+ .description('Create a workspace with its chief of staff (and the install, if there is none)')
137
145
  .argument('<workspace>', 'the workspace id, such as personal')
138
146
  .option('--name <name>', 'its display name (default: from the id)')
139
147
  .option('--channel <channel>', "chat in this channel instead of the bot's direct messages")
@@ -154,8 +162,13 @@ export function createProgram(io) {
154
162
  throw new Error(`No engine "${options.engine}". Choose one of: ${known}`);
155
163
  }
156
164
  const setup = entry.engine;
165
+ const home = resolve(options.home ?? defaultHome(io.env));
166
+ // On a fresh machine, init makes the install too, so setup can be scripted.
167
+ const createdInstall = await ensureInstall(paths(home, io.env));
168
+ if (createdInstall)
169
+ io.out(`Created the install at ${home} (a git folder)\n`);
157
170
  const dir = await initWorkspace({
158
- home: resolve(options.home ?? defaultHome(io.env)),
171
+ home,
159
172
  id,
160
173
  env: io.env,
161
174
  engine: {
@@ -206,8 +219,45 @@ export function createProgram(io) {
206
219
  function shellPath(path) {
207
220
  return /^[\w./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`;
208
221
  }
209
- /** Runs the CLI and returns the exit code. */
222
+ /**
223
+ * The cloud winks once. Ctrl+C meanwhile still gives the terminal its cursor back before
224
+ * stopping, as it would have done without the wink.
225
+ */
226
+ async function winkOnce(io) {
227
+ const interrupted = () => {
228
+ io.out(SHOW_CURSOR);
229
+ process.exit(130);
230
+ };
231
+ process.once('SIGINT', interrupted);
232
+ try {
233
+ await wink({ version: packageVersion(), color: io.color, write: io.out });
234
+ }
235
+ finally {
236
+ process.off('SIGINT', interrupted);
237
+ }
238
+ }
239
+ /** Runs the CLI and returns the exit code. With no command, it shows the welcome screen. */
210
240
  export async function run(argv, io) {
241
+ if (argv.length === 0) {
242
+ const home = resolve(defaultHome(io.env));
243
+ // The welcome never fails: a problem reading the install becomes one of its lines.
244
+ // The status is read while the cloud is drawn and winks.
245
+ const reading = welcomeStatus(home, io.env).catch((error) => [
246
+ {
247
+ mark: '!',
248
+ text: `Couldn't read the install at ${home}: ${error instanceof Error ? error.message : String(error)}. Run wheems doctor.`,
249
+ },
250
+ ]);
251
+ io.out(renderWelcomeHead({
252
+ version: packageVersion(),
253
+ color: io.color,
254
+ cloud: showCloud(io.columns),
255
+ }));
256
+ if (canWink(io.columns, io.env))
257
+ await winkOnce(io);
258
+ io.out(renderWelcomeTail({ status: await reading, color: io.color }));
259
+ return 0;
260
+ }
211
261
  const program = createProgram(io);
212
262
  try {
213
263
  await program.parseAsync(argv, { from: 'user' });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The cloud: the mascot's outline (the brand kit's mascot.svg), traced in Unicode half blocks,
3
+ * with its eye and smile placed by hand so they read at this size. Half blocks are in every
4
+ * monospace font and most terminals draw them as exact rectangles, so it looks the same
5
+ * everywhere; an outline also reads on light and dark backgrounds alike. It has no colour of
6
+ * its own: it's drawn in the terminal's text colour, like the brand's ink app icon, so it's white
7
+ * on a dark terminal and near-black on a light one.
8
+ */
9
+ export declare const CLOUD: string[];
10
+ /** The brand line under the name. */
11
+ export declare const TAGLINE = "Your crew, ready for work.";
12
+ /** The columns the cloud and the words beside it need. */
13
+ export declare const WELCOME_WIDTH: number;
14
+ /**
15
+ * Whether to draw the cloud: only in a terminal wide enough for it and the words beside it.
16
+ * Piped into a file or another program, or read out by a screen reader, it would only be
17
+ * noise. `columns` is undefined when the output isn't a terminal.
18
+ */
19
+ export declare function showCloud(columns: number | undefined): boolean;
20
+ export interface WelcomeStatus {
21
+ mark: '✓' | '!' | '-';
22
+ text: string;
23
+ }
24
+ interface WelcomeOptions {
25
+ version: string;
26
+ status: WelcomeStatus[];
27
+ color: boolean;
28
+ /** Ends with the commands to try next. On by default. */
29
+ hints?: boolean;
30
+ /** Draws the cloud. On by default; see showCloud for when to leave it out. */
31
+ cloud?: boolean;
32
+ }
33
+ /** The welcome screen: the cloud with the name and tagline beside it, then the install's status. */
34
+ export declare function renderWelcome(options: WelcomeOptions): string;
35
+ /** The top of the welcome screen: the cloud and the words beside it, or just the words. */
36
+ export declare function renderWelcomeHead(options: Omit<WelcomeOptions, 'status'>): string;
37
+ /** The rest of the welcome screen: the install's status and the commands to try. */
38
+ export declare function renderWelcomeTail(options: Omit<WelcomeOptions, 'version' | 'cloud'>): string;
39
+ /**
40
+ * Whether the cloud may wink: only when it's drawn, so in a wide enough terminal, and not in
41
+ * CI, a dumb terminal, or for someone who has set WHEEMS_NO_ANIMATION.
42
+ */
43
+ export declare function canWink(columns: number | undefined, env: NodeJS.ProcessEnv): boolean;
44
+ /** How long the eye stays open before the wink, and how long it stays shut. */
45
+ export declare const WINK_TIMING: {
46
+ readonly before: 400;
47
+ readonly shut: 150;
48
+ };
49
+ /**
50
+ * The cloud winks once: run straight after renderWelcomeHead is written, with the cursor on the
51
+ * line under the cloud. It redraws only the eye's row, hides the cursor meanwhile and always
52
+ * shows it again.
53
+ */
54
+ export declare function wink(options: {
55
+ version: string;
56
+ color: boolean;
57
+ write: (text: string) => void;
58
+ sleep?: (ms: number) => Promise<void>;
59
+ }): Promise<void>;
60
+ export declare const HIDE_CURSOR = "\u001B[?25l";
61
+ export declare const SHOW_CURSOR = "\u001B[?25h";
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 declare function welcomeStatus(home: string, env: NodeJS.ProcessEnv, ping?: (socket: string) => Promise<boolean>): Promise<WelcomeStatus[]>;
68
+ export {};
@@ -0,0 +1,163 @@
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: the mascot's outline (the brand kit's mascot.svg), traced in Unicode half blocks,
10
+ * with its eye and smile placed by hand so they read at this size. Half blocks are in every
11
+ * monospace font and most terminals draw them as exact rectangles, so it looks the same
12
+ * everywhere; an outline also reads on light and dark backgrounds alike. It has no colour of
13
+ * its own: it's drawn in the terminal's text colour, like the brand's ink app icon, so it's white
14
+ * on a dark terminal and near-black on a light one.
15
+ */
16
+ // Every character is one UTF-16 unit, so .length is the width in columns.
17
+ export const CLOUD = [
18
+ ' ▄█████▄▄',
19
+ ' ▄█▀ ▀▀█▄',
20
+ ' ▄█████▀ ▀█▄',
21
+ ' ██ ██',
22
+ ' ▄██ █▀',
23
+ ' ██▀ ██ ▀█▄▄',
24
+ '██ ▀█▄',
25
+ '██ ▀▄▄▀ ██',
26
+ ' ██▄ ▄█▀',
27
+ ' ▀▀█████████████████▀',
28
+ ];
29
+ /** The brand line under the name. */
30
+ export const TAGLINE = 'Your crew, ready for work.';
31
+ const CLOUD_WIDTH = Math.max(...CLOUD.map((line) => line.length));
32
+ /** The columns the cloud and the words beside it need. */
33
+ export const WELCOME_WIDTH = CLOUD_WIDTH + 3 + TAGLINE.length;
34
+ /**
35
+ * Whether to draw the cloud: only in a terminal wide enough for it and the words beside it.
36
+ * Piped into a file or another program, or read out by a screen reader, it would only be
37
+ * noise. `columns` is undefined when the output isn't a terminal.
38
+ */
39
+ export function showCloud(columns) {
40
+ return columns !== undefined && columns >= WELCOME_WIDTH;
41
+ }
42
+ const BOLD = '\x1b[1m';
43
+ const DIM = '\x1b[2m';
44
+ const RESET = '\x1b[0m';
45
+ /** The welcome screen: the cloud with the name and tagline beside it, then the install's status. */
46
+ export function renderWelcome(options) {
47
+ return renderWelcomeHead(options) + renderWelcomeTail(options);
48
+ }
49
+ /** The row of the cloud with the eye in it, and the eye's columns. */
50
+ const EYE = { row: 5, from: 6, to: 8 };
51
+ function cloudRow(options, row, line) {
52
+ const paint = (code, text) => (options.color ? `${code}${text}${RESET}` : text);
53
+ const beside = {
54
+ 4: paint(BOLD, 'wheems') + ' ' + paint(DIM, options.version),
55
+ 5: TAGLINE,
56
+ };
57
+ const text = beside[row];
58
+ return text === undefined ? line : `${line.padEnd(CLOUD_WIDTH)} ${text}`;
59
+ }
60
+ /** The top of the welcome screen: the cloud and the words beside it, or just the words. */
61
+ export function renderWelcomeHead(options) {
62
+ const paint = (code, text) => (options.color ? `${code}${text}${RESET}` : text);
63
+ const lines = options.cloud === false
64
+ ? [paint(BOLD, 'wheems') + ' ' + paint(DIM, options.version), TAGLINE]
65
+ : CLOUD.map((line, row) => cloudRow(options, row, line));
66
+ return ['', ...lines].join('\n') + '\n';
67
+ }
68
+ /** The rest of the welcome screen: the install's status and the commands to try. */
69
+ export function renderWelcomeTail(options) {
70
+ const paint = (code, text) => (options.color ? `${code}${text}${RESET}` : text);
71
+ const marks = { '✓': '\x1b[32m', '!': '\x1b[33m', '-': DIM };
72
+ return [
73
+ '',
74
+ ...options.status.map((s) => `${paint(marks[s.mark], s.mark)} ${s.text}`),
75
+ ...(options.status.length > 0 ? [''] : []),
76
+ ...(options.hints === false
77
+ ? []
78
+ : [paint(DIM, 'wheems setup · wheems doctor · wheems --help'), '']),
79
+ ].join('\n');
80
+ }
81
+ /**
82
+ * Whether the cloud may wink: only when it's drawn, so in a wide enough terminal, and not in
83
+ * CI, a dumb terminal, or for someone who has set WHEEMS_NO_ANIMATION.
84
+ */
85
+ export function canWink(columns, env) {
86
+ return (showCloud(columns) &&
87
+ env.TERM !== 'dumb' &&
88
+ (env.CI ?? '') === '' &&
89
+ (env.WHEEMS_NO_ANIMATION ?? '') === '');
90
+ }
91
+ /** How long the eye stays open before the wink, and how long it stays shut. */
92
+ export const WINK_TIMING = { before: 400, shut: 150 };
93
+ /**
94
+ * The cloud winks once: run straight after renderWelcomeHead is written, with the cursor on the
95
+ * line under the cloud. It redraws only the eye's row, hides the cursor meanwhile and always
96
+ * shows it again.
97
+ */
98
+ export async function wink(options) {
99
+ const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
100
+ const up = CLOUD.length - EYE.row;
101
+ const open = CLOUD[EYE.row] ?? '';
102
+ const shut = open.slice(0, EYE.from) + '▄▄' + open.slice(EYE.to);
103
+ // Up to the eye's row, clear it, draw it again, and back down to where the cursor was.
104
+ const redraw = (line) => {
105
+ options.write(`\x1b[${String(up)}A\r\x1b[2K${cloudRow(options, EYE.row, line)}\x1b[${String(up)}B\r`);
106
+ };
107
+ options.write(HIDE_CURSOR);
108
+ try {
109
+ await sleep(WINK_TIMING.before);
110
+ redraw(shut);
111
+ await sleep(WINK_TIMING.shut);
112
+ redraw(open);
113
+ }
114
+ finally {
115
+ options.write(SHOW_CURSOR);
116
+ }
117
+ }
118
+ export const HIDE_CURSOR = '\x1b[?25l';
119
+ export const SHOW_CURSOR = '\x1b[?25h';
120
+ /**
121
+ * What the welcome screen says about this install: its workspaces and agents, the service,
122
+ * search, and anything waiting for review. Quick: it reads files and pings the service, and
123
+ * never starts a session.
124
+ */
125
+ export async function welcomeStatus(home, env, ping = (socket) => request(socket, { op: 'ping' }, 1000).then((answer) => answer.ok, () => false)) {
126
+ const where = paths(home, env);
127
+ const hasInstall = await readFile(join(home, 'install.yaml')).then(() => true, () => false);
128
+ if (!hasInstall) {
129
+ return [{ mark: '!', text: `No install at ${tilde(home, env)} yet: run wheems setup` }];
130
+ }
131
+ const install = await loadInstall(home);
132
+ const workspaces = install.workspaces.filter((w) => w.config);
133
+ const agents = workspaces.reduce((n, w) => n + w.agents.length, 0);
134
+ const status = [];
135
+ status.push(workspaces.length > 0
136
+ ? {
137
+ mark: '✓',
138
+ text: `${count(workspaces.length, 'workspace')}, ${count(agents, 'agent')} in ${tilde(home, env)}`,
139
+ }
140
+ : { mark: '!', text: 'No workspaces yet: run wheems setup' });
141
+ status.push((await ping(where.socket))
142
+ ? { mark: '✓', text: 'The service is running' }
143
+ : { mark: '!', text: "The service isn't running: wheems service install" });
144
+ const embeddings = install.config?.knowledge.embeddings;
145
+ status.push(embeddings
146
+ ? { mark: '✓', text: `Search by meaning with ${embeddings.model ?? embeddings.driver}` }
147
+ : { mark: '-', text: 'Search by keyword only (wheems setup → Knowledge for meaning)' });
148
+ const waiting = (await listProposals(where.state)).length + (await listAboutYou(where.state)).length;
149
+ if (waiting > 0) {
150
+ status.push({ mark: '!', text: `${count(waiting, 'thing')} waiting for you: wheems review` });
151
+ }
152
+ return status;
153
+ }
154
+ function count(n, noun) {
155
+ return `${String(n)} ${noun}${n === 1 ? '' : 's'}`;
156
+ }
157
+ function tilde(path, env) {
158
+ const home = env.HOME;
159
+ return home !== undefined && home !== '' && path.startsWith(`${home}/`)
160
+ ? `~${path.slice(home.length)}`
161
+ : path;
162
+ }
163
+ //# 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';