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 +46 -0
- package/README.md +26 -10
- package/dist/cli/commands/service.js +4 -5
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/main.js +6 -6
- package/dist/cli/program.d.ts +1 -1
- package/dist/cli/program.js +24 -5
- package/dist/cli/welcome.d.ts +25 -0
- package/dist/cli/welcome.js +105 -0
- package/dist/contracts/channel.d.ts +15 -1
- package/dist/doctor/doctor.d.ts +2 -1
- package/dist/doctor/doctor.js +73 -21
- package/dist/drivers/channel/slack/slack-channel.d.ts +1 -0
- package/dist/drivers/channel/slack/slack-channel.js +23 -2
- package/dist/drivers/engine/claude-code.d.ts +2 -0
- package/dist/drivers/engine/claude-code.js +43 -1
- package/dist/drivers/runtime/herdr/hc.js +1 -1
- package/dist/drivers/runtime/herdr/herdr-runtime.js +1 -1
- package/dist/drivers/vector-store/sqlite.js +2 -1
- package/dist/knowledge/search.d.ts +5 -0
- package/dist/knowledge/search.js +3 -1
- package/dist/secrets.d.ts +1 -1
- package/dist/service/conversations.js +24 -5
- package/dist/service/launchd.js +2 -2
- package/dist/service/service.js +8 -3
- package/dist/testing/memory-channel.d.ts +3 -0
- package/dist/testing/memory-channel.js +1 -0
- package/dist/util/node-support.d.ts +11 -0
- package/dist/util/node-support.js +21 -0
- package/dist/util/sqlite.d.ts +12 -0
- package/dist/util/sqlite.js +35 -0
- package/package.json +3 -3
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
|
|
82
|
-
|
|
83
|
-
-
|
|
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`
|
|
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
|
|
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
|
|
230
|
-
Watch it or take over with
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
3
|
-
// Node.js gets a plain message rather than a stack trace from a dependency.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
package/dist/cli/program.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/program.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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';
|
package/dist/doctor/doctor.d.ts
CHANGED
|
@@ -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;
|
package/dist/doctor/doctor.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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: '
|
|
373
|
+
name: 'SQLite search',
|
|
328
374
|
status: 'fail',
|
|
329
|
-
message:
|
|
330
|
-
hint: `Install
|
|
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
|
|
154
|
-
|
|
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 [
|
|
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:
|
|
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 {
|
package/dist/knowledge/search.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
413
|
+
sentAt,
|
|
395
414
|
seen: seen ? [seen] : [],
|
|
396
415
|
queued: [],
|
|
397
416
|
closed: false,
|
package/dist/service/launchd.js
CHANGED
|
@@ -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
|
|
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
|
|
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) {
|
package/dist/service/service.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
68
|
+
channelId = channel.resolveChannel
|
|
69
|
+
? await channel.resolveChannel(chat.channel)
|
|
70
|
+
: chat.channel;
|
|
70
71
|
if (channelId === null) {
|
|
71
|
-
log(`Workspace ${workspace.id}: the
|
|
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: {
|
|
@@ -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
|
package/dist/util/sqlite.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/util/sqlite.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
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 .",
|