@agentshouse/kit 0.2.1-alpha.19 → 0.2.1-alpha.20

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/README.md CHANGED
@@ -48,6 +48,10 @@ workspace. Commit in one and run `house git push --owner` there to send the
48
48
  commit to House; in the container, run it through the install command from the
49
49
  copy's directory. Inside an Agent conversation the Agent runs `house git push`.
50
50
 
51
+ House Kit turns each audio file of your message to an Agent into text on this
52
+ computer before the Agent reads the message. The first audio file downloads the
53
+ speech engine and model, about 425 MB, into `~/.house-kit`.
54
+
51
55
  ## License
52
56
 
53
57
  MIT
package/dist/abort.js ADDED
@@ -0,0 +1,9 @@
1
+ export function unlessAborted(work, signal) {
2
+ return new Promise((resolve, reject) => {
3
+ const aborted = () => reject(signal.reason);
4
+ signal.addEventListener('abort', aborted, { once: true });
5
+ if (signal.aborted)
6
+ aborted();
7
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted));
8
+ });
9
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.2.1-alpha.19",
3
+ "version": "0.2.1-alpha.20",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.2.1-alpha.19/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.2.1-alpha.20/connect-linux.sh)",
7
7
  "asset": {
8
8
  "name": "connect-linux.sh",
9
- "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-alpha.19/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-alpha.20/connect-linux.sh"
10
10
  },
11
11
  "workspace": {
12
12
  "argument": "--workspace",
@@ -17,10 +17,10 @@
17
17
  }
18
18
  },
19
19
  "windows": {
20
- "command": "& ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.2.1-alpha.19/connect-windows.ps1)))",
20
+ "command": "& ([scriptblock]::Create((irm https://github.com/agentshouse/kit/releases/download/v0.2.1-alpha.20/connect-windows.ps1)))",
21
21
  "asset": {
22
22
  "name": "connect-windows.ps1",
23
- "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-alpha.19/connect-windows.ps1"
23
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-alpha.20/connect-windows.ps1"
24
24
  },
25
25
  "workspace": {
26
26
  "argument": "--workspace",
package/dist/clis.js CHANGED
@@ -111,28 +111,29 @@ async function installedAdapter(kind) {
111
111
  export function withoutProxy() {
112
112
  return Object.fromEntries(Object.entries(process.env).filter(([name]) => !PROXY.test(name)));
113
113
  }
114
- export function run(command, args, timeoutMs, detached = false) {
114
+ export function run(command, args, timeoutMs, detached = false, signal) {
115
115
  return new Promise((resolve) => {
116
116
  const child = spawn(command, args, {
117
117
  stdio: ['ignore', 'pipe', 'pipe'],
118
118
  timeout: timeoutMs,
119
119
  env: withoutProxy(),
120
120
  detached,
121
+ signal,
121
122
  });
122
123
  let stdout = '';
123
124
  let output = '';
124
- child.stdout.on('data', (chunk) => {
125
- stdout += chunk.toString('utf8');
126
- output += chunk.toString('utf8');
125
+ child.stdout.setEncoding('utf8').on('data', (chunk) => {
126
+ stdout += chunk;
127
+ output += chunk;
127
128
  });
128
- child.stderr.on('data', (chunk) => {
129
- output += chunk.toString('utf8');
129
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
130
+ output += chunk;
130
131
  });
131
132
  child.on('error', (error) => resolve({ status: null, stdout, output: error.message }));
132
133
  child.on('close', (status) => resolve({ status, stdout, output }));
133
134
  });
134
135
  }
135
- function failureOf(ran, program) {
136
+ export function failureOf(ran, program) {
136
137
  return ran.output.trim().slice(-CAUSE_CHARACTERS) || `${program} exited with ${ran.status ?? 'a signal'}`;
137
138
  }
138
139
  export async function installAdapter(kind) {
@@ -2,6 +2,7 @@ import { client, } from '@agentclientprotocol/sdk';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { unlessAborted } from './abort.js';
5
6
  import { killMarked, startAdapter, TURN_ENDED, TURN_STARTED } from './acp.js';
6
7
  import { blocksOf, firstChange } from './blocks.js';
7
8
  import { openBridge } from './bridge.js';
@@ -11,6 +12,7 @@ import { agentBase } from './home.js';
11
12
  import { instructions } from './instructions.js';
12
13
  import { holdSecretInput } from './secret-input.js';
13
14
  import { createHowWeWork } from './skills.js';
15
+ import { transcribe } from './transcription.js';
14
16
  const KILLED = 'the conversation was killed';
15
17
  function causeOf(error) {
16
18
  return error instanceof Error ? error.message : String(error);
@@ -21,15 +23,6 @@ function answerMessage(request, response) {
21
23
  function logged(error) {
22
24
  process.stderr.write(`kit: ${causeOf(error)}\n`);
23
25
  }
24
- function unlessKilled(work, signal) {
25
- return new Promise((resolve, reject) => {
26
- const killed = () => reject(signal.reason);
27
- signal.addEventListener('abort', killed, { once: true });
28
- if (signal.aborted)
29
- killed();
30
- work.then(resolve, reject).finally(() => signal.removeEventListener('abort', killed));
31
- });
32
- }
33
26
  function stop(bridge) {
34
27
  killMarked(`HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`);
35
28
  }
@@ -60,6 +53,8 @@ class Conversation {
60
53
  opening = null;
61
54
  kills = 0;
62
55
  killed = new AbortController();
56
+ interrupts = 0;
57
+ interrupted = new AbortController();
63
58
  open = 0;
64
59
  queue = Promise.resolve();
65
60
  reports = Promise.resolve();
@@ -95,6 +90,10 @@ export class Conversations {
95
90
  conversation.killed.abort(new Error(KILLED));
96
91
  this.halt(conversation);
97
92
  }
93
+ if (input.kind === 'interrupt') {
94
+ conversation.interrupts++;
95
+ conversation.interrupted.abort();
96
+ }
98
97
  const work = conversation.queue.then(() => this.carry(conversation, input));
99
98
  conversation.queue = work.catch(() => undefined);
100
99
  this.acks.set(input.input_id, work);
@@ -138,8 +137,11 @@ export class Conversations {
138
137
  return this.open(conversation, input);
139
138
  if (input.kind === 'message')
140
139
  return this.message(conversation, input);
141
- if (input.kind === 'interrupt')
140
+ if (input.kind === 'interrupt') {
141
+ if (--conversation.interrupts === 0)
142
+ conversation.interrupted = new AbortController();
142
143
  await this.interrupt(conversation, String(input.turn_id));
144
+ }
143
145
  if (input.kind === 'kill') {
144
146
  await this.kill(conversation);
145
147
  if (--conversation.kills === 0)
@@ -171,7 +173,9 @@ export class Conversations {
171
173
  const prompt = [];
172
174
  try {
173
175
  const route = await this.route(conversation, input.agent_id);
174
- const paths = await placeFiles(this.kit.house, route.working_directory, input.files, conversation.killed.signal);
176
+ const files = input.files;
177
+ const paths = await placeFiles(this.kit.house, route.working_directory, files, conversation.killed.signal);
178
+ const lines = await this.transcribed(conversation, files, paths);
175
179
  if (conversation.running === null) {
176
180
  const opened = await this.start(conversation, route, input.provider_session_id);
177
181
  if (input.provider_session_id === null)
@@ -184,8 +188,8 @@ export class Conversations {
184
188
  });
185
189
  }
186
190
  prompt.push({ type: 'text', text: String(input.text) });
187
- if (paths.length > 0)
188
- prompt.push({ type: 'text', text: paths.join('\n') });
191
+ if (lines.length > 0)
192
+ prompt.push({ type: 'text', text: lines.join('\n') });
189
193
  if (conversation.kills > 0)
190
194
  throw new Error(KILLED);
191
195
  }
@@ -197,6 +201,25 @@ export class Conversations {
197
201
  this.prompt(conversation, prompt);
198
202
  return ack;
199
203
  }
204
+ async transcribed(conversation, files, paths) {
205
+ const lines = [];
206
+ for (const [index, path] of paths.entries()) {
207
+ lines.push(path);
208
+ if (files[index].media_type?.startsWith('audio/') !== true)
209
+ continue;
210
+ try {
211
+ const text = await transcribe(path, AbortSignal.any([conversation.killed.signal, conversation.interrupted.signal]));
212
+ if (text !== '')
213
+ lines.push(`Transcript: ${text}`);
214
+ }
215
+ catch (error) {
216
+ if (conversation.kills > 0)
217
+ throw error;
218
+ logged(new Error(`${files[index].name} was not transcribed: ${causeOf(error)}`));
219
+ }
220
+ }
221
+ return lines;
222
+ }
200
223
  async interrupt(conversation, turnId) {
201
224
  if (conversation.running === null || conversation.turn?.id !== turnId)
202
225
  return;
@@ -235,9 +258,9 @@ export class Conversations {
235
258
  }
236
259
  async route(conversation, agentId) {
237
260
  const signal = conversation.killed.signal;
238
- await unlessKilled(this.kit.agents.read(), signal);
261
+ await unlessAborted(this.kit.agents.read(), signal);
239
262
  if (this.kit.agents.route(agentId) === undefined)
240
- await unlessKilled(this.kit.agents.refresh(), signal);
263
+ await unlessAborted(this.kit.agents.refresh(), signal);
241
264
  const route = this.kit.agents.route(agentId);
242
265
  if (route === undefined)
243
266
  throw new Error(`this Environment hosts no Agent ${agentId}`);
@@ -253,7 +276,7 @@ export class Conversations {
253
276
  .onNotification(TURN_ENDED, (params) => params, () => this.ended(conversation))
254
277
  .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
255
278
  .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
256
- const cli = await unlessKilled(this.kit.agents.cli(route.kind), conversation.killed.signal);
279
+ const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
257
280
  const bridge = await openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
258
281
  if (conversation.kills > 0) {
259
282
  bridge.close();
@@ -261,7 +284,7 @@ export class Conversations {
261
284
  }
262
285
  conversation.opening = bridge;
263
286
  try {
264
- await unlessKilled(createHowWeWork(bridge), conversation.killed.signal);
287
+ await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
265
288
  const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (job) => this.job(conversation, job), bridge.env);
266
289
  void adapter.exited.then(() => bridge.close());
267
290
  const agent = adapter.connection.agent;
@@ -0,0 +1,118 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
4
+ import { availableParallelism, tmpdir } from 'node:os';
5
+ import { basename, join } from 'node:path';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { unlessAborted } from './abort.js';
8
+ import { failureOf, run } from './clis.js';
9
+ import { kitHome } from './home.js';
10
+ const RELEASE = 'https://github.com/agentshouse/kit/releases/download/engine-1';
11
+ export const MODEL = {
12
+ url: 'https://huggingface.co/ggml-org/parakeet-GGUF/resolve/35156454d1a39de06863303dd209fd2bed6ee079/ggml-parakeet-tdt-0.6b-v3-q4_k.bin',
13
+ sha256: '8b205b8b39c6535e153de6fb11c51db46125d45c4f16ba496fe41a0fe71b885e',
14
+ executable: false,
15
+ };
16
+ export const ENGINES = {
17
+ 'linux-x64': {
18
+ decoder: { url: `${RELEASE}/ffmpeg-linux-x86_64`, sha256: '1f8235d206428305afb89c6e8ab99d100425a0ba61e22bb4c7aff017db8c8de5', executable: true },
19
+ engine: { url: `${RELEASE}/parakeet-cli-linux-x86_64`, sha256: '259fd7a1c83f9768be45d15ef88965f82ce86c481215cfe34f778f9890b3a833', executable: true },
20
+ },
21
+ 'linux-arm64': {
22
+ decoder: { url: `${RELEASE}/ffmpeg-linux-aarch64`, sha256: 'a7e08a0fae0179d1078312f6bb13d2a9aa4d4c71a1d82a022953f25dee452f5e', executable: true },
23
+ engine: { url: `${RELEASE}/parakeet-cli-linux-aarch64`, sha256: 'e2585bc37a30baf48b16e19d18e56920cba78c05da8dc0dea2b0dab0b26caab9', executable: true },
24
+ },
25
+ };
26
+ const CHUNK_SECONDS = 20;
27
+ let held = Promise.resolve();
28
+ function directory() {
29
+ return join(kitHome(), 'transcription');
30
+ }
31
+ function installed(artifact) {
32
+ return join(directory(), artifact.sha256);
33
+ }
34
+ async function executed(command, args, signal) {
35
+ const ran = await run(command, args, 0, false, signal);
36
+ if (ran.status !== 0)
37
+ throw new Error(failureOf(ran, basename(command)));
38
+ return ran.stdout;
39
+ }
40
+ function present(path) {
41
+ return stat(path).then(() => true, () => false);
42
+ }
43
+ async function cpus() {
44
+ const [quota, period] = (await readFile('/sys/fs/cgroup/cpu.max', 'utf8').catch(() => 'max')).split(' ');
45
+ const allowed = quota === 'max' ? Infinity : Math.ceil(Number(quota) / Number(period));
46
+ return Math.min(availableParallelism(), allowed);
47
+ }
48
+ async function sha256(path) {
49
+ const hash = createHash('sha256');
50
+ await pipeline(createReadStream(path), hash);
51
+ return hash.digest('hex');
52
+ }
53
+ async function install(artifacts, signal) {
54
+ await mkdir(directory(), { recursive: true });
55
+ const pinned = new Set(artifacts.map((artifact) => artifact.sha256));
56
+ for (const name of await readdir(directory())) {
57
+ if (!pinned.has(name))
58
+ await rm(join(directory(), name), { recursive: true, force: true });
59
+ }
60
+ const missing = [];
61
+ for (const artifact of artifacts)
62
+ if (!(await present(installed(artifact))))
63
+ missing.push(artifact);
64
+ try {
65
+ for (const artifact of missing) {
66
+ const part = `${installed(artifact)}.part`;
67
+ await executed('curl', ['-fsSL', '--retry', '3', '-o', part, artifact.url], signal);
68
+ if ((await sha256(part)) !== artifact.sha256)
69
+ throw new Error(`${artifact.url} does not match its sha256`);
70
+ }
71
+ for (const artifact of missing) {
72
+ if (artifact.executable)
73
+ await chmod(`${installed(artifact)}.part`, 0o755);
74
+ await rename(`${installed(artifact)}.part`, installed(artifact));
75
+ }
76
+ }
77
+ finally {
78
+ for (const artifact of missing)
79
+ await rm(`${installed(artifact)}.part`, { force: true });
80
+ }
81
+ }
82
+ async function transcribed(path, signal) {
83
+ const platform = `${process.platform}-${process.arch}`;
84
+ const pinned = ENGINES[platform];
85
+ if (pinned === undefined)
86
+ throw new Error(`no transcription engine is pinned for ${platform}`);
87
+ await install([pinned.decoder, pinned.engine, MODEL], signal);
88
+ const work = await mkdtemp(join(tmpdir(), 'kit-transcription-'));
89
+ try {
90
+ await executed(installed(pinned.decoder), ['-nostdin', '-hide_banner', '-loglevel', 'error', '-i', path, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le',
91
+ '-f', 'segment', '-segment_time', String(CHUNK_SECONDS), join(work, '%05d.wav')], signal);
92
+ const parts = (await readdir(work)).sort().flatMap((part) => ['-f', join(work, part)]);
93
+ const text = await executed(installed(pinned.engine), ['-t', String(await cpus()), '-np', '-m', installed(MODEL), ...parts], signal);
94
+ return text
95
+ .split('\n')
96
+ .map((line) => line.trim())
97
+ .filter((line) => line !== '')
98
+ .join(' ');
99
+ }
100
+ finally {
101
+ await rm(work, { recursive: true, force: true });
102
+ }
103
+ }
104
+ export async function transcribe(path, signal) {
105
+ const before = held;
106
+ let release;
107
+ const mine = new Promise((resolve) => {
108
+ release = resolve;
109
+ });
110
+ held = Promise.all([before, mine]);
111
+ try {
112
+ await unlessAborted(before, signal);
113
+ return await transcribed(path, signal);
114
+ }
115
+ finally {
116
+ release();
117
+ }
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.2.1-alpha.19",
3
+ "version": "0.2.1-alpha.20",
4
4
  "description": "House Kit connects an Environment to agents.house.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,7 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "build": "tsc -p tsconfig.build.json && node scripts/bootstrap-manifest.mjs",
25
+ "engine": "mkdir -p tmp/engine && docker run --rm -v \"$PWD:/kit\" -w /kit alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 sh scripts/engine.sh tmp/engine",
25
26
  "test": "tsc -p tsconfig.json && vitest run"
26
27
  },
27
28
  "dependencies": {