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 +68 -0
- package/README.md +44 -10
- package/dist/cli/commands/service.js +4 -5
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/main.js +7 -6
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +55 -5
- package/dist/cli/welcome.d.ts +68 -0
- package/dist/cli/welcome.js +163 -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/connect.d.ts +5 -0
- package/dist/drivers/channel/slack/connect.js +53 -3
- 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/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. */
|
|
@@ -11,3 +11,8 @@ export interface SlackConnectOptions {
|
|
|
11
11
|
* its two tokens (checked with Slack), and say who may talk to agents.
|
|
12
12
|
*/
|
|
13
13
|
export declare function slackConnectFlow(options?: SlackConnectOptions): ConnectFlow;
|
|
14
|
+
/**
|
|
15
|
+
* What's wrong with a pasted token, said so the person can fix it, or undefined if it looks
|
|
16
|
+
* right. It names a token only by its standard prefix, never by any other part of it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function tokenProblem(kind: 'bot' | 'app', value: string): string | undefined;
|
|
@@ -93,7 +93,8 @@ async function newTokens(ui, context, verify) {
|
|
|
93
93
|
copied
|
|
94
94
|
? '2. Paste the manifest: it is on your clipboard.'
|
|
95
95
|
: `2. Paste the manifest from ${manifestFile}`,
|
|
96
|
-
'3. Install the app to the workspace
|
|
96
|
+
'3. Install the app to the workspace. Under OAuth & Permissions, copy the',
|
|
97
|
+
' Bot User OAuth Token (xoxb-…).',
|
|
97
98
|
'4. Under Basic Information → App-Level Tokens, generate a token with the',
|
|
98
99
|
' connections:write scope (xapp-…).',
|
|
99
100
|
'',
|
|
@@ -106,11 +107,11 @@ async function newTokens(ui, context, verify) {
|
|
|
106
107
|
for (;;) {
|
|
107
108
|
const botToken = await ui.secret({
|
|
108
109
|
message: 'Bot User OAuth Token',
|
|
109
|
-
validate: (value) =>
|
|
110
|
+
validate: (value) => tokenProblem('bot', value),
|
|
110
111
|
});
|
|
111
112
|
const appToken = await ui.secret({
|
|
112
113
|
message: 'App-Level Token',
|
|
113
|
-
validate: (value) =>
|
|
114
|
+
validate: (value) => tokenProblem('app', value),
|
|
114
115
|
});
|
|
115
116
|
try {
|
|
116
117
|
await ui.task('Checking the tokens with Slack', () => verify(botToken.trim(), appToken.trim()), (id) => `Connected as ${id.botUser} in ${id.team}`);
|
|
@@ -123,4 +124,53 @@ async function newTokens(ui, context, verify) {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
}
|
|
127
|
+
/** What each token is called in Slack, where to find it, and how it starts. */
|
|
128
|
+
const TOKENS = {
|
|
129
|
+
bot: {
|
|
130
|
+
name: 'bot token',
|
|
131
|
+
prefix: 'xoxb-',
|
|
132
|
+
where: 'Under OAuth & Permissions, copy the Bot User OAuth Token',
|
|
133
|
+
},
|
|
134
|
+
app: {
|
|
135
|
+
name: 'app-level token',
|
|
136
|
+
prefix: 'xapp-',
|
|
137
|
+
where: 'Under Basic Information → App-Level Tokens, copy the token',
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
/** Other Slack tokens people paste by mistake, by their standard prefix. */
|
|
141
|
+
const OTHER_TOKENS = {
|
|
142
|
+
'xoxp-': 'a user token',
|
|
143
|
+
'xoxe-': 'a refresh token',
|
|
144
|
+
'xoxa-': 'a workspace token',
|
|
145
|
+
};
|
|
146
|
+
const CLEAR = 'Clear the field with Backspace, then paste it once.';
|
|
147
|
+
/**
|
|
148
|
+
* What's wrong with a pasted token, said so the person can fix it, or undefined if it looks
|
|
149
|
+
* right. It names a token only by its standard prefix, never by any other part of it.
|
|
150
|
+
*/
|
|
151
|
+
export function tokenProblem(kind, value) {
|
|
152
|
+
const want = TOKENS[kind];
|
|
153
|
+
const other = TOKENS[kind === 'bot' ? 'app' : 'bot'];
|
|
154
|
+
const token = value.trim();
|
|
155
|
+
const fix = `${want.where} (${want.prefix}…).`;
|
|
156
|
+
if (token === '')
|
|
157
|
+
return `Paste the ${want.name}. ${fix}`;
|
|
158
|
+
if (token.split(want.prefix).length > 2) {
|
|
159
|
+
return `That looks like more than one paste. ${CLEAR}`;
|
|
160
|
+
}
|
|
161
|
+
if (token.startsWith(want.prefix)) {
|
|
162
|
+
return /\s/.test(token) ? `There's a space or line break in it. ${CLEAR}` : undefined;
|
|
163
|
+
}
|
|
164
|
+
if (token.includes(want.prefix)) {
|
|
165
|
+
return `There's something before the ${want.prefix} at the start. ${CLEAR}`;
|
|
166
|
+
}
|
|
167
|
+
if (token.startsWith(other.prefix)) {
|
|
168
|
+
return `That starts with ${other.prefix}, so it's the ${other.name}. ${fix}`;
|
|
169
|
+
}
|
|
170
|
+
const known = Object.entries(OTHER_TOKENS).find(([prefix]) => token.startsWith(prefix));
|
|
171
|
+
if (known !== undefined) {
|
|
172
|
+
return `That starts with ${known[0]}, so it's ${known[1]}, not the ${want.name}. ${fix}`;
|
|
173
|
+
}
|
|
174
|
+
return `That isn't the ${want.name}: it starts with ${want.prefix}. ${fix}`;
|
|
175
|
+
}
|
|
126
176
|
//# sourceMappingURL=connect.js.map
|
|
@@ -24,6 +24,7 @@ export declare class SlackChannel implements ChannelDriver {
|
|
|
24
24
|
* Returns null when the bot cannot see a channel by that name.
|
|
25
25
|
*/
|
|
26
26
|
resolveChannel(nameOrId: string): Promise<string | null>;
|
|
27
|
+
isMember(channelId: string): Promise<boolean | null>;
|
|
27
28
|
stop(): Promise<void>;
|
|
28
29
|
private onEvent;
|
|
29
30
|
private toDriverError;
|
|
@@ -139,6 +139,16 @@ export class SlackChannel {
|
|
|
139
139
|
} while (cursor !== undefined && !this.channelIds.has(name));
|
|
140
140
|
return this.channelIds.get(name) ?? null;
|
|
141
141
|
}
|
|
142
|
+
async isMember(channelId) {
|
|
143
|
+
try {
|
|
144
|
+
const info = await this.transport.call('conversations.info', { channel: channelId });
|
|
145
|
+
const member = info.channel?.is_member;
|
|
146
|
+
return typeof member === 'boolean' ? member : null;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
142
152
|
async stop() {
|
|
143
153
|
this.handler = undefined;
|
|
144
154
|
if (!this.started)
|
|
@@ -150,8 +160,9 @@ export class SlackChannel {
|
|
|
150
160
|
const handler = this.handler;
|
|
151
161
|
if (!handler || event.type !== 'message')
|
|
152
162
|
return;
|
|
153
|
-
// Edits, deletions, joins and bot posts arrive as subtypes; only plain messages
|
|
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 {};
|