@stage-labs/metro 0.1.0-beta.235 → 0.1.0-beta.237
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/dist/claude.js +1 -1
- package/dist/cli.js +14 -5
- package/dist/memory.js +2 -2
- package/dist/voice.js +39 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/MEMORY.md +2 -2
- package/runtime/node_modules/@metro-labs/daemon/package.json +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/schedule-detail.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/memory-routine.ts +25 -16
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +8 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/api.ts +14 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +153 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +95 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +154 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +54 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +84 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +102 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speakable.ts +54 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +91 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/store.ts +97 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +234 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +2 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +11 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +21 -3
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +2 -1
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +2 -0
package/dist/claude.js
CHANGED
|
@@ -9,7 +9,7 @@ import { localPort, localUrl } from './runtime.js';
|
|
|
9
9
|
import { keepInSession, takeBackConversation } from './background.js';
|
|
10
10
|
const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
|
|
11
11
|
const FRESH_PROMPT_FLAGS = ['--system-prompt-snapshot', 'off'];
|
|
12
|
-
const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
|
|
12
|
+
export const PERMISSION_MODE_FLAG = { auto: 'auto', bypass: 'bypassPermissions' };
|
|
13
13
|
const KEY_HEADER = 'x-metro-key';
|
|
14
14
|
const PROBE_MS = 3_000;
|
|
15
15
|
const AUTH_STATUS_MS = 20_000;
|
package/dist/cli.js
CHANGED
|
@@ -3,11 +3,12 @@ import { whoisAuthorized } from './api.js';
|
|
|
3
3
|
import { stopAll } from './control.js';
|
|
4
4
|
import { tailEvents } from './tail.js';
|
|
5
5
|
import { launchClaude } from './claude.js';
|
|
6
|
-
import { memoryRoutine } from './memory.js';
|
|
6
|
+
import { memoryPaths, memoryRoutine } from './memory.js';
|
|
7
7
|
import { update } from './update.js';
|
|
8
8
|
import { serve } from './serve.js';
|
|
9
9
|
import { service, serviceStopHint } from './service.js';
|
|
10
10
|
import { currentVersion } from './version.js';
|
|
11
|
+
import { voiceSession } from './voice.js';
|
|
11
12
|
const USAGE = `metro: run your agent on this machine
|
|
12
13
|
|
|
13
14
|
metro serve [--port <n>] [--owner <organization id>]
|
|
@@ -32,9 +33,16 @@ const USAGE = `metro: run your agent on this machine
|
|
|
32
33
|
open Claude Code with the metro channel and the metro MCP server, its
|
|
33
34
|
inference routed through the daemon's model gateway (the Model page);
|
|
34
35
|
every argument is passed through the same
|
|
35
|
-
metro memory
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
metro memory [skill file]
|
|
37
|
+
run the memory routine once: when Claude Code transcripts changed since
|
|
38
|
+
the last run, Claude Code reads the memory skill (or the file given) and
|
|
39
|
+
files them into its memory, with no metro tools and writes only inside
|
|
40
|
+
the memory folder
|
|
41
|
+
metro voice --model <model>
|
|
42
|
+
the agent's side of a voice call, which the daemon starts when a call
|
|
43
|
+
comes in: Claude Code reads the call's words as stream-json on stdin and
|
|
44
|
+
streams its replies on stdout, with the agent's memory, skills and tools
|
|
45
|
+
but no metro channel
|
|
38
46
|
metro update update to the newest published version (--check only reports)
|
|
39
47
|
metro version print this CLI's version
|
|
40
48
|
|
|
@@ -68,7 +76,8 @@ const COMMANDS = {
|
|
|
68
76
|
stop: stopDaemon,
|
|
69
77
|
tail: () => tailEvents(process.argv.slice(3)),
|
|
70
78
|
claude: () => launchClaude(process.argv.slice(3)),
|
|
71
|
-
memory: () => memoryRoutine(),
|
|
79
|
+
memory: () => memoryRoutine(memoryPaths(process.env, process.argv[3])),
|
|
80
|
+
voice: () => voiceSession(process.argv.slice(3)),
|
|
72
81
|
update: () => update(process.argv.slice(3)),
|
|
73
82
|
version: async () => {
|
|
74
83
|
process.stdout.write(`${currentVersion()}\n`);
|
package/dist/memory.js
CHANGED
|
@@ -46,13 +46,13 @@ function customDir(claude, home) {
|
|
|
46
46
|
return undefined;
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
export function memoryPaths(env = process.env) {
|
|
49
|
+
export function memoryPaths(env = process.env, skill) {
|
|
50
50
|
const home = given(env.HOME) ?? homedir();
|
|
51
51
|
const claude = claudeDir(env);
|
|
52
52
|
return {
|
|
53
53
|
claude,
|
|
54
54
|
memory: customDir(claude, home) ?? join(projectDir(claude, realHome(home)), 'memory'),
|
|
55
|
-
skill: join(claude, 'skills', 'memory', 'SKILL.md'),
|
|
55
|
+
skill: given(skill) ?? join(claude, 'skills', 'memory', 'SKILL.md'),
|
|
56
56
|
state: join(home, '.metro', 'memory-routine.json'),
|
|
57
57
|
lock: join(home, '.metro', 'memory-routine.lock'),
|
|
58
58
|
};
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { headlessEnv, PERMISSION_MODE_FLAG, runClaude } from './claude.js';
|
|
3
|
+
import { permissionMode, systemPrompt } from './route.js';
|
|
4
|
+
const VOICE_PROMPT = `You are on a live voice call through Metro. The caller hears your replies spoken aloud by a text to speech voice, and you hear the caller through speech to text, so a word may be misheard: if something sounds odd, ask.
|
|
5
|
+
- Speak like a person on the phone: short sentences, usually one to three. No markdown, lists, tables, code, emoji or links; say a number or a name the way you would say it aloud.
|
|
6
|
+
- Start with the answer. Do not repeat the question.
|
|
7
|
+
- Before you use a tool, first say one short sentence about what you are checking.
|
|
8
|
+
- Look things up yourself with Read, Grep or Glob when it is quick. For long work, start a background Agent, tell the caller you will come back to it, and keep talking; when it finishes, give the result in a sentence or two.
|
|
9
|
+
- Text in [square brackets] comes from the call system, not from the caller.
|
|
10
|
+
- You cannot send chat messages during the call. When the call ends, you will be asked for call notes, which are posted to the chat.`;
|
|
11
|
+
function voiceArgs(model, mode, prompt) {
|
|
12
|
+
return [
|
|
13
|
+
'-p',
|
|
14
|
+
'--input-format',
|
|
15
|
+
'stream-json',
|
|
16
|
+
'--output-format',
|
|
17
|
+
'stream-json',
|
|
18
|
+
'--verbose',
|
|
19
|
+
'--include-partial-messages',
|
|
20
|
+
'--model',
|
|
21
|
+
model,
|
|
22
|
+
'--permission-mode',
|
|
23
|
+
PERMISSION_MODE_FLAG[mode],
|
|
24
|
+
'--settings',
|
|
25
|
+
JSON.stringify({ enabledPlugins: { 'metro@metro': false } }),
|
|
26
|
+
'--strict-mcp-config',
|
|
27
|
+
'--no-session-persistence',
|
|
28
|
+
'--append-system-prompt',
|
|
29
|
+
prompt === null ? VOICE_PROMPT : `${prompt}\n\n${VOICE_PROMPT}`,
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
export async function voiceSession(argv) {
|
|
33
|
+
const at = argv.indexOf('--model');
|
|
34
|
+
const model = at < 0 ? '' : (argv[at + 1] ?? '');
|
|
35
|
+
if (model === '')
|
|
36
|
+
throw new Error('metro voice needs --model <model>');
|
|
37
|
+
process.chdir(homedir());
|
|
38
|
+
return runClaude(voiceArgs(model, permissionMode(), systemPrompt()), await headlessEnv());
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.237",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -3,7 +3,7 @@ name: memory
|
|
|
3
3
|
description: How this agent keeps its long-term memory in the Instinct layout (people, organizations, facts, preferences, decisions, conversation digests, daily and weekly timelines, active and completed work), the note format and rules, and the daily routine that files each day into it. Use when saving or looking something up in memory, and when the daily memory routine runs.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Your memory is a folder of Markdown notes, found with grep and links, not a database. The layout follows Instinct (https://supermemory.ai/blog/reverse-engineering-instinct-memory/).
|
|
6
|
+
Your memory is a folder of Markdown notes, found with grep and links, not a database. The layout follows Instinct (https://supermemory.ai/blog/reverse-engineering-instinct-memory/). Twice a day, at 00:00 and 12:00 UTC, a scheduled run (the `memory-routine` job on the Scheduled page) reads this skill and files what happened into it. You can use the same rules any time you save something.
|
|
7
7
|
|
|
8
8
|
**Where it is.** The auto-memory directory of your Claude Code home folder: `~/.claude/projects/<home folder, with every character that is not a letter or digit turned into ->/memory/` (for `/home/agent` that is `~/.claude/projects/-home-agent/memory/`), or the `autoMemoryDirectory` set in `~/.claude/settings.json`. The daily run names it in its prompt.
|
|
9
9
|
|
|
@@ -72,7 +72,7 @@ The scheduled run has no chat tools, no shell, no network and no subagents. It c
|
|
|
72
72
|
- Work goes into `workstreams/active/`. When it finished, only set its `status` in place (see Rules).
|
|
73
73
|
- Each chat line gets a digest in `comms/phone/`.
|
|
74
74
|
- Turn passing anecdotes into general traits. Drop trivia.
|
|
75
|
-
4. Write `timeline/daily/YYYY-MM-DD.md` for each UTC day with activity since the last run: what happened, with links. Things that need the owner go as one line each under a `## Needs <owner's name>` heading, since this run cannot message anyone.
|
|
75
|
+
4. Write `timeline/daily/YYYY-MM-DD.md` for each UTC day with activity since the last run: what happened, with links. A note an earlier run already wrote gets added to. Things that need the owner go as one line each under a `## Needs <owner's name>` heading, since this run cannot message anyone.
|
|
76
76
|
5. If the prompt names a previous ISO week whose `timeline/weekly/YYYY-Www.md` does not exist yet, and there are daily notes for that week, write it from those daily notes.
|
|
77
77
|
6. Tidy up. Merge duplicates into one note, and keep the other slug in its `aliases`. Cut the other note down to its frontmatter and one line: `Merged into [[slug]].` Fix broken `[[links]]` and update `MEMORY.md`.
|
|
78
78
|
7. End with at most three lines on what changed.
|
|
@@ -29,8 +29,8 @@ function textFile(path: string): string | null {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export function scriptOf(command: string): { path: string; text: string } | null {
|
|
32
|
-
const words = command.split(/\s+/).filter((w) => w.startsWith('/') && !w.includes('>'));
|
|
33
|
-
for (const path of words.
|
|
32
|
+
const words = command.split(/\s+/).filter((w) => w.startsWith('/') && !w.includes('>')).slice(0, 3);
|
|
33
|
+
for (const path of [...words.filter((w) => w.endsWith('.md')), ...words]) {
|
|
34
34
|
const text = textFile(path);
|
|
35
35
|
if (text !== null) return { path, text };
|
|
36
36
|
}
|
|
@@ -52,6 +52,7 @@ import type { StationName } from '@metro-labs/core/station-names';
|
|
|
52
52
|
import { startUploadReaper } from '../files/upload-store.js';
|
|
53
53
|
import { startAttachReaper } from '../files/attach-reaper.js';
|
|
54
54
|
import { startCloudWatchPublisher } from '../server/cloudwatch.js';
|
|
55
|
+
import { isCallEvent, onCallEvent } from '../voice/calls.js';
|
|
55
56
|
|
|
56
57
|
installCrashGuard();
|
|
57
58
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
@@ -67,6 +68,10 @@ const supervisor = new TrainSupervisor();
|
|
|
67
68
|
const emit = makeEmit();
|
|
68
69
|
|
|
69
70
|
supervisor.onTrainEvent((env, train) => {
|
|
71
|
+
if (isCallEvent(env)) {
|
|
72
|
+
onCallEvent(env);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
70
75
|
const entry = trainEventToMetroEvent(env, train);
|
|
71
76
|
if (entry) emit(entry);
|
|
72
77
|
});
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { hostname } from 'node:os';
|
|
3
1
|
import { join } from 'node:path';
|
|
4
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
3
|
import { existsSync, readFileSync } from '../agent-user/agent-fs.js';
|
|
@@ -10,8 +8,9 @@ import { agentPath, agentUser, type AgentUser } from '../agent-user/user.js';
|
|
|
10
8
|
|
|
11
9
|
const BEGIN = '# metro memory routine: begin';
|
|
12
10
|
const END = '# metro memory routine: end';
|
|
11
|
+
const REPLACED = '# replaced by memory-routine: ';
|
|
13
12
|
const OWN_JOB = /memory/i;
|
|
14
|
-
const
|
|
13
|
+
const SCHEDULE = '0 0,12 * * *';
|
|
15
14
|
|
|
16
15
|
export type MemoryJobState = 'scheduled' | 'own' | 'off' | 'unavailable';
|
|
17
16
|
|
|
@@ -25,7 +24,6 @@ export interface MemoryJobDeps {
|
|
|
25
24
|
runner?: Runner;
|
|
26
25
|
cli?: string;
|
|
27
26
|
port?: string;
|
|
28
|
-
seed?: string;
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
let last: MemoryJob = { state: 'unavailable', job: null };
|
|
@@ -33,6 +31,7 @@ let last: MemoryJob = { state: 'unavailable', job: null };
|
|
|
33
31
|
export const memoryJobStatus = (): MemoryJob => ({ ...last });
|
|
34
32
|
|
|
35
33
|
export const launcherPath = (user: AgentUser): string => join(user.home, '.metro', 'bin', 'memory-routine');
|
|
34
|
+
export const skillPath = (user: AgentUser): string => join(user.home, '.claude', 'skills', 'memory', 'SKILL.md');
|
|
36
35
|
const logPath = (user: AgentUser): string => join(user.home, '.metro', 'memory-routine.log');
|
|
37
36
|
const quoted = (text: string): string => `'${text.replace(/'/g, '\'\\\'\'')}'`;
|
|
38
37
|
|
|
@@ -43,28 +42,38 @@ export function launcherText(user: AgentUser, cli: string, port: string): string
|
|
|
43
42
|
'export PATH',
|
|
44
43
|
...(port === '' ? [] : [`METRO_WEBHOOK_PORT=${quoted(port)}`, 'export METRO_WEBHOOK_PORT']),
|
|
45
44
|
'cd "$HOME" || exit 1',
|
|
46
|
-
`exec node ${quoted(cli)} memory`,
|
|
45
|
+
`exec node ${quoted(cli)} memory "$@"`,
|
|
47
46
|
'',
|
|
48
47
|
].join('\n');
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
export
|
|
52
|
-
const minute = parseInt(createHash('sha256').update(seed).digest('hex').slice(0, 8), 16) % 60;
|
|
53
|
-
return `${String(minute)} ${String(HOUR)} * * * ${launcherPath(user)} >> ${logPath(user)} 2>&1`;
|
|
54
|
-
}
|
|
50
|
+
export const cronLine = (user: AgentUser): string => `${SCHEDULE} ${launcherPath(user)} ${skillPath(user)} >> ${logPath(user)} 2>&1`;
|
|
55
51
|
|
|
56
52
|
const commandName = (command: string): string => command.split(/\s+/)[0]?.split('/').pop() ?? 'cron';
|
|
57
53
|
|
|
54
|
+
const ownCommand = (user: AgentUser, line: string): string | null => {
|
|
55
|
+
const parsed = parseCronLine(line);
|
|
56
|
+
return parsed === null || parsed.command.includes(launcherPath(user)) || !OWN_JOB.test(parsed.command) ? null : parsed.command;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const replaces = (user: AgentUser, line: string): boolean => OWN_JOB.test(commandName(ownCommand(user, line) ?? ''));
|
|
60
|
+
|
|
58
61
|
function ownJob(user: AgentUser, runner: Runner, lines: string[]): string | null {
|
|
59
|
-
const launcher = launcherPath(user);
|
|
60
62
|
const cron = lines.flatMap((line) => {
|
|
61
|
-
const
|
|
62
|
-
return
|
|
63
|
+
const command = ownCommand(user, line);
|
|
64
|
+
return command === null || replaces(user, line) ? [] : [commandName(command)];
|
|
63
65
|
});
|
|
64
66
|
const timers = listSchedules(null, runner).filter((job) => job.runsAs === user.name && OWN_JOB.test(`${job.name} ${job.command}`));
|
|
65
67
|
return cron[0] ?? timers[0]?.name ?? null;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
const restored = (line: string): string => (line.startsWith(REPLACED) ? line.slice(REPLACED.length) : line);
|
|
71
|
+
|
|
72
|
+
function marked(user: AgentUser, lines: string[], on: boolean, scheduled: boolean): string[] {
|
|
73
|
+
if (!on) return lines.map(restored);
|
|
74
|
+
return lines.map((l) => (scheduled && replaces(user, l) ? `${REPLACED}${l}` : l));
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
function placeLauncher(path: string, text: string): void {
|
|
69
78
|
if (existsSync(path) && readFileSync(path, 'utf8') === text) return;
|
|
70
79
|
writeHomeText(path, text, 0o755);
|
|
@@ -78,7 +87,6 @@ function resolved(deps: MemoryJobDeps): Required<MemoryJobDeps> {
|
|
|
78
87
|
runner: deps.runner ?? realRunner,
|
|
79
88
|
cli: deps.cli ?? envText('METRO_CLI_BIN'),
|
|
80
89
|
port: deps.port ?? envText('METRO_WEBHOOK_PORT'),
|
|
81
|
-
seed: deps.seed ?? hostname(),
|
|
82
90
|
};
|
|
83
91
|
}
|
|
84
92
|
|
|
@@ -88,7 +96,7 @@ const stateOf = (scheduled: boolean, own: string | null): MemoryJobState => {
|
|
|
88
96
|
};
|
|
89
97
|
|
|
90
98
|
export function ensureMemoryJob(on: boolean, deps: MemoryJobDeps = {}): MemoryJob {
|
|
91
|
-
const { user, runner, cli, port
|
|
99
|
+
const { user, runner, cli, port } = resolved(deps);
|
|
92
100
|
if (user === null || cli === '') {
|
|
93
101
|
last = { state: 'unavailable', job: null };
|
|
94
102
|
return memoryJobStatus();
|
|
@@ -98,10 +106,11 @@ export function ensureMemoryJob(on: boolean, deps: MemoryJobDeps = {}): MemoryJo
|
|
|
98
106
|
const scheduled = on && own === null;
|
|
99
107
|
const launcher = launcherPath(user);
|
|
100
108
|
if (scheduled) placeLauncher(launcher, launcherText(user, cli, port));
|
|
101
|
-
const
|
|
109
|
+
const kept = marked(user, lines, on, scheduled);
|
|
110
|
+
const next = withBlock(kept, BEGIN, END, scheduled ? [cronLine(user)] : [], 'end', (l) => l.includes(launcher));
|
|
102
111
|
const changed = writeCrontab(user, runner, lines, next);
|
|
103
112
|
last = { state: stateOf(scheduled, own), job: own };
|
|
104
|
-
if (changed) log.info({ ...last }, 'memory-routine: updated the
|
|
113
|
+
if (changed) log.info({ ...last, rewritten: kept.filter((l, i) => l !== lines[i]) }, 'memory-routine: updated the memory job');
|
|
105
114
|
return memoryJobStatus();
|
|
106
115
|
}
|
|
107
116
|
|
|
@@ -110,13 +110,17 @@ export function hasConversation(home: string, dir = claudeDir()): boolean {
|
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
function
|
|
113
|
+
export function metroCli(args: string[], fixed?: string[]): string[] {
|
|
114
114
|
const bin = process.env.METRO_CLI_BIN?.trim() ?? '';
|
|
115
115
|
const runtime = agentUser() === null ? process.execPath : 'node';
|
|
116
|
-
const base =
|
|
117
|
-
const continues = (deps.continues ?? hasConversation)(home);
|
|
116
|
+
const base = fixed ?? (bin === '' ? ['metro', ...args] : [runtime, bin, ...args]);
|
|
118
117
|
const env = Object.entries(sessionEnv()).map(([k, v]) => `${k}=${v}`);
|
|
119
|
-
|
|
118
|
+
return env.length === 0 || fixed !== undefined ? base : ['env', ...env, ...base];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function metroCommand(deps: SessionDeps, home: string): string[] {
|
|
122
|
+
const command = metroCli(['claude'], deps.metro);
|
|
123
|
+
const continues = (deps.continues ?? hasConversation)(home);
|
|
120
124
|
return continues ? [...command, '-c'] : command;
|
|
121
125
|
}
|
|
122
126
|
|
|
@@ -166,7 +166,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
|
166
166
|
'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
|
|
167
167
|
'08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
|
|
168
168
|
]);
|
|
169
|
-
const PRIOR_MEMORY: ReadonlySet<string> = new Set();
|
|
169
|
+
const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
|
|
170
170
|
|
|
171
171
|
interface ShippedSkill {
|
|
172
172
|
name: string;
|
|
@@ -21,6 +21,7 @@ import { handleTerminalRequest, type TerminalApiDeps } from '../terminal/api.js'
|
|
|
21
21
|
import { handleSchedulesRequest } from '../agent-user/schedules-api.js';
|
|
22
22
|
import { handleFilesRequest } from '../agent-user/files-api.js';
|
|
23
23
|
import { handleVaultRequest } from '../vault/api.js';
|
|
24
|
+
import { handleVoiceRequest } from '../voice/api.js';
|
|
24
25
|
|
|
25
26
|
export interface SessionApis {
|
|
26
27
|
agentApi?: AgentApiDeps;
|
|
@@ -58,6 +59,7 @@ export function handleSessionApis(
|
|
|
58
59
|
() => handleSchedulesRequest(req, res),
|
|
59
60
|
() => handleFilesRequest(req, res),
|
|
60
61
|
() => handleVaultRequest(req, res),
|
|
62
|
+
() => handleVoiceRequest(req, res),
|
|
61
63
|
...when(apis.ownerApi, (d) => handleOwnerRequest(req, res, d)),
|
|
62
64
|
...when(apis.machineApi, (d) => handleMachineRequest(req, res, d)),
|
|
63
65
|
...when(apis.modelApi, (d) => handleModelRequest(req, res, d)),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
|
|
3
|
+
import { patchVoice, publicVoice, readVoice, writeVoice } from './store.js';
|
|
4
|
+
|
|
5
|
+
const PATH = '/api/voice';
|
|
6
|
+
|
|
7
|
+
async function answer(req: IncomingMessage): Promise<unknown> {
|
|
8
|
+
if (req.method === 'PUT') writeVoice(patchVoice(readVoice(), await readJsonBody(req)));
|
|
9
|
+
return publicVoice(readVoice());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function handleVoiceRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
|
13
|
+
return sessionRoute(req, res, { methods: { [PATH]: ['GET', 'PUT'] }, admin: ['PUT'], label: 'voice-api' }, () => answer(req));
|
|
14
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import OpusScript from 'opusscript';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
|
|
4
|
+
const RATE = 48_000;
|
|
5
|
+
const FRAME = 960;
|
|
6
|
+
const FRAME_MS = 20;
|
|
7
|
+
const MAX_LATE_FRAMES = 5;
|
|
8
|
+
|
|
9
|
+
interface Frame {
|
|
10
|
+
pcm: Int16Array;
|
|
11
|
+
chars: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function downsample3(pcm: Int16Array): Int16Array {
|
|
15
|
+
const out = new Int16Array(Math.floor(pcm.length / 3));
|
|
16
|
+
for (let i = 0; i < out.length; i += 1)
|
|
17
|
+
out[i] = Math.round(((pcm[i * 3] ?? 0) + (pcm[i * 3 + 1] ?? 0) + (pcm[i * 3 + 2] ?? 0)) / 3);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function upsample2(pcm: Int16Array, carry: number): Int16Array {
|
|
22
|
+
const out = new Int16Array(pcm.length * 2);
|
|
23
|
+
let prev = carry;
|
|
24
|
+
for (let i = 0; i < pcm.length; i += 1) {
|
|
25
|
+
const cur = pcm[i] ?? 0;
|
|
26
|
+
out[i * 2] = Math.round((prev + cur) / 2);
|
|
27
|
+
out[i * 2 + 1] = cur;
|
|
28
|
+
prev = cur;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const pcmOf = (bytes: Buffer): Int16Array => {
|
|
34
|
+
const copy = Buffer.from(bytes);
|
|
35
|
+
return new Int16Array(copy.buffer, copy.byteOffset, Math.floor(copy.length / 2));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const bytesOf = (pcm: Int16Array): Buffer => Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
|
39
|
+
|
|
40
|
+
export class OpusIn {
|
|
41
|
+
private readonly decoder = new OpusScript(RATE, 1, OpusScript.Application.VOIP);
|
|
42
|
+
|
|
43
|
+
decode(packet: Buffer): Int16Array | null {
|
|
44
|
+
try {
|
|
45
|
+
return pcmOf(this.decoder.decode(packet));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log.debug({ err: errMsg(err) }, 'voice: an audio packet could not be decoded');
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
close(): void {
|
|
53
|
+
this.decoder.delete();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Playback {
|
|
58
|
+
send(opus: Buffer, timestamp: number, marker: boolean): void;
|
|
59
|
+
played(chars: number): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class Player {
|
|
63
|
+
private readonly encoder = new OpusScript(RATE, 1, OpusScript.Application.VOIP);
|
|
64
|
+
private readonly queue: Frame[] = [];
|
|
65
|
+
private pending = new Int16Array(0);
|
|
66
|
+
private pendingChars = 0;
|
|
67
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
+
private started = 0;
|
|
69
|
+
private sent = 0;
|
|
70
|
+
private speaking = false;
|
|
71
|
+
private onIdle: (() => void) | null = null;
|
|
72
|
+
private onFirst: (() => void) | null = null;
|
|
73
|
+
|
|
74
|
+
constructor(private readonly out: Playback) {}
|
|
75
|
+
|
|
76
|
+
get busy(): boolean {
|
|
77
|
+
return this.queue.length > 0 || this.pending.length > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
whenFirstPlayed(fn: () => void): void {
|
|
81
|
+
this.onFirst = fn;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
whenIdle(fn: () => void): void {
|
|
85
|
+
this.onIdle = fn;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
push(pcm: Int16Array, chars: number): void {
|
|
89
|
+
const joined = new Int16Array(this.pending.length + pcm.length);
|
|
90
|
+
joined.set(this.pending);
|
|
91
|
+
joined.set(pcm, this.pending.length);
|
|
92
|
+
let at = 0;
|
|
93
|
+
for (; at + FRAME <= joined.length; at += FRAME) this.queue.push({ pcm: joined.slice(at, at + FRAME), chars: 0 });
|
|
94
|
+
this.pending = joined.slice(at);
|
|
95
|
+
this.pendingChars += chars;
|
|
96
|
+
const last = this.queue.at(-1);
|
|
97
|
+
if (last !== undefined && this.pending.length === 0) {
|
|
98
|
+
last.chars += this.pendingChars;
|
|
99
|
+
this.pendingChars = 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
finish(): void {
|
|
104
|
+
if (this.pending.length === 0) return;
|
|
105
|
+
const pcm = new Int16Array(FRAME);
|
|
106
|
+
pcm.set(this.pending);
|
|
107
|
+
this.queue.push({ pcm, chars: this.pendingChars });
|
|
108
|
+
this.pending = new Int16Array(0);
|
|
109
|
+
this.pendingChars = 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
clear(): void {
|
|
113
|
+
this.queue.length = 0;
|
|
114
|
+
this.pending = new Int16Array(0);
|
|
115
|
+
this.pendingChars = 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
start(): void {
|
|
119
|
+
if (this.timer !== null) return;
|
|
120
|
+
this.started = performance.now();
|
|
121
|
+
this.sent = 0;
|
|
122
|
+
this.tick();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stop(): void {
|
|
126
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
127
|
+
this.timer = null;
|
|
128
|
+
this.encoder.delete();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private tick(): void {
|
|
132
|
+
const due = Math.floor((performance.now() - this.started) / FRAME_MS) + 1;
|
|
133
|
+
if (due - this.sent > MAX_LATE_FRAMES) this.sent = due - 1;
|
|
134
|
+
while (this.sent < due) this.sendOne();
|
|
135
|
+
const next = this.started + this.sent * FRAME_MS;
|
|
136
|
+
this.timer = setTimeout(() => {
|
|
137
|
+
this.tick();
|
|
138
|
+
}, Math.max(0, next - performance.now()));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private sendOne(): void {
|
|
142
|
+
const frame = this.queue.shift();
|
|
143
|
+
const marker = frame !== undefined && !this.speaking;
|
|
144
|
+
this.speaking = frame !== undefined;
|
|
145
|
+
const pcm = frame?.pcm ?? new Int16Array(FRAME);
|
|
146
|
+
this.out.send(this.encoder.encode(bytesOf(pcm), FRAME), (this.sent * FRAME) >>> 0, marker);
|
|
147
|
+
this.sent += 1;
|
|
148
|
+
if (frame === undefined) return;
|
|
149
|
+
if (marker) this.onFirst?.();
|
|
150
|
+
if (frame.chars > 0) this.out.played(frame.chars);
|
|
151
|
+
if (!this.busy) this.onIdle?.();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { drainLines } from '@metro-labs/core/trains/protocol';
|
|
5
|
+
import { asAgent } from '../agent-user/user.js';
|
|
6
|
+
import { metroCli } from '../claude/session.js';
|
|
7
|
+
|
|
8
|
+
const CLOSE_GRACE_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
export interface Thinking {
|
|
11
|
+
turnStarted(): void;
|
|
12
|
+
text(delta: string): void;
|
|
13
|
+
tool(name: string): void;
|
|
14
|
+
turnEnded(text: string): void;
|
|
15
|
+
exited(reason: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const record = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
|
19
|
+
|
|
20
|
+
function streamEvent(msg: Record<string, unknown>, mind: Thinking): void {
|
|
21
|
+
if (msg.parent_tool_use_id !== null && msg.parent_tool_use_id !== undefined) return;
|
|
22
|
+
const event = record(msg.event);
|
|
23
|
+
const delta = record(event.delta);
|
|
24
|
+
if (event.type === 'content_block_delta' && delta.type === 'text_delta' && typeof delta.text === 'string') mind.text(delta.text);
|
|
25
|
+
const block = record(event.content_block);
|
|
26
|
+
if (event.type === 'content_block_start' && block.type === 'tool_use') mind.tool(typeof block.name === 'string' ? block.name : 'tool');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readBrainLine(line: string, mind: Thinking): void {
|
|
30
|
+
let parsed: unknown;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(line);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const msg = record(parsed);
|
|
37
|
+
if (msg.type === 'system' && msg.subtype === 'init') mind.turnStarted();
|
|
38
|
+
else if (msg.type === 'stream_event') streamEvent(msg, mind);
|
|
39
|
+
else if (msg.type === 'result') mind.turnEnded(typeof msg.result === 'string' ? msg.result : '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class Brain {
|
|
43
|
+
private readonly child: ChildProcessWithoutNullStreams;
|
|
44
|
+
private out = '';
|
|
45
|
+
private gone = false;
|
|
46
|
+
|
|
47
|
+
constructor(model: string, private readonly mind: Thinking) {
|
|
48
|
+
const [command = 'metro', ...args] = metroCli(['voice', '--model', model]);
|
|
49
|
+
const [file, argv] = asAgent(command, args);
|
|
50
|
+
this.child = spawn(file, argv, { stdio: ['pipe', 'pipe', 'pipe'], cwd: '/' });
|
|
51
|
+
this.child.stdout.setEncoding('utf8');
|
|
52
|
+
this.child.stdout.on('data', (chunk: string) => {
|
|
53
|
+
this.out = drainLines('voice-brain', this.out + chunk, (line) => {
|
|
54
|
+
readBrainLine(line, this.mind);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
this.child.stderr.setEncoding('utf8');
|
|
58
|
+
this.child.stderr.on('data', (chunk: string) => {
|
|
59
|
+
log.debug({ text: chunk.slice(0, 500) }, 'voice: brain stderr');
|
|
60
|
+
});
|
|
61
|
+
this.child.stdin.on('error', (err) => {
|
|
62
|
+
log.debug({ err: errMsg(err) }, 'voice: brain stdin closed');
|
|
63
|
+
});
|
|
64
|
+
this.child.on('error', (err) => {
|
|
65
|
+
this.exit(`could not start: ${errMsg(err)}`);
|
|
66
|
+
});
|
|
67
|
+
this.child.on('exit', (code, signal) => {
|
|
68
|
+
this.exit(`exited (${String(code ?? signal)})`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get alive(): boolean {
|
|
73
|
+
return !this.gone;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
tell(text: string, now = false): void {
|
|
77
|
+
if (this.gone) return;
|
|
78
|
+
const message = { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: '' };
|
|
79
|
+
this.child.stdin.write(`${JSON.stringify(now ? { ...message, priority: 'now' } : message)}\n`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
close(): void {
|
|
83
|
+
if (this.gone) return;
|
|
84
|
+
this.child.stdin.end();
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
if (!this.gone) this.child.kill('SIGKILL');
|
|
87
|
+
}, CLOSE_GRACE_MS).unref();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private exit(reason: string): void {
|
|
91
|
+
if (this.gone) return;
|
|
92
|
+
this.gone = true;
|
|
93
|
+
this.mind.exited(reason);
|
|
94
|
+
}
|
|
95
|
+
}
|