clearclaw 0.8.0 → 0.9.0
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/channel/telegram.d.ts +1 -1
- package/dist/channel/telegram.js +10 -3
- package/dist/orchestrator.d.ts +8 -1
- package/dist/orchestrator.js +32 -0
- package/dist/prompt.js +21 -10
- package/dist/transcribe.d.ts +13 -0
- package/dist/transcribe.js +157 -0
- package/package.json +1 -1
|
@@ -41,7 +41,7 @@ export declare class TelegramChannel extends EventEmitter implements Channel {
|
|
|
41
41
|
private extractSender;
|
|
42
42
|
/** Extract reply context from a Telegram message, if it's a reply. */
|
|
43
43
|
private extractReplyContext;
|
|
44
|
-
/** Shared handler for
|
|
44
|
+
/** Shared handler for file messages: download, emit attachment or fallback. */
|
|
45
45
|
private handleFileMessage;
|
|
46
46
|
sendFile(chatId: string, buffer: Buffer, filename: string, opts?: SendFileOpts): Promise<void>;
|
|
47
47
|
reactToMessage(chatId: string, messageId: string, emoji: string): Promise<void>;
|
package/dist/channel/telegram.js
CHANGED
|
@@ -82,10 +82,17 @@ export class TelegramChannel extends EventEmitter {
|
|
|
82
82
|
const doc = ctx.message.document;
|
|
83
83
|
await this.handleFileMessage(ctx, doc.file_id, doc.mime_type ?? "application/octet-stream", doc.file_name, ctx.message.caption);
|
|
84
84
|
});
|
|
85
|
+
// Voice notes and audio files: the orchestrator transcribes them
|
|
86
|
+
this.bot.on("message:voice", async (ctx) => {
|
|
87
|
+
const voice = ctx.message.voice;
|
|
88
|
+
await this.handleFileMessage(ctx, voice.file_id, voice.mime_type ?? "audio/ogg", "voice.ogg", ctx.message.caption);
|
|
89
|
+
});
|
|
90
|
+
this.bot.on("message:audio", async (ctx) => {
|
|
91
|
+
const audio = ctx.message.audio;
|
|
92
|
+
await this.handleFileMessage(ctx, audio.file_id, audio.mime_type ?? "audio/mpeg", audio.file_name, ctx.message.caption);
|
|
93
|
+
});
|
|
85
94
|
// Placeholder handlers for unsupported media types
|
|
86
95
|
for (const [filter, label] of [
|
|
87
|
-
["message:voice", "Voice message"],
|
|
88
|
-
["message:audio", "Audio message"],
|
|
89
96
|
["message:video", "Video"],
|
|
90
97
|
["message:sticker", "Sticker"],
|
|
91
98
|
]) {
|
|
@@ -399,7 +406,7 @@ export class TelegramChannel extends EventEmitter {
|
|
|
399
406
|
mediaType,
|
|
400
407
|
};
|
|
401
408
|
}
|
|
402
|
-
/** Shared handler for
|
|
409
|
+
/** Shared handler for file messages: download, emit attachment or fallback. */
|
|
403
410
|
async handleFileMessage(ctx, fileId, mimeType, filename, caption) {
|
|
404
411
|
const sender = this.extractSender(ctx);
|
|
405
412
|
if (!sender)
|
package/dist/orchestrator.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Config } from "./config.js";
|
|
2
|
-
import type { Channel, Engine, InboundMessage, MessageOrigin } from "./types.js";
|
|
2
|
+
import type { Attachment, Channel, Engine, InboundMessage, MessageOrigin } from "./types.js";
|
|
3
3
|
export interface OrchestratorOpts {
|
|
4
4
|
channel: Channel;
|
|
5
5
|
engines: Map<string, Engine>;
|
|
6
6
|
config: Config;
|
|
7
|
+
transcribe?: (att: Attachment) => Promise<string>;
|
|
7
8
|
}
|
|
8
9
|
export declare class Orchestrator {
|
|
9
10
|
private channel;
|
|
@@ -13,6 +14,7 @@ export declare class Orchestrator {
|
|
|
13
14
|
private creatingWorkspaces;
|
|
14
15
|
private scheduler;
|
|
15
16
|
private planUsage;
|
|
17
|
+
private transcribe;
|
|
16
18
|
constructor(opts: OrchestratorOpts);
|
|
17
19
|
private chat;
|
|
18
20
|
private engineFor;
|
|
@@ -46,6 +48,11 @@ export declare class Orchestrator {
|
|
|
46
48
|
private persistSessionId;
|
|
47
49
|
/** Native selection works even when the workspace's engine cannot run. */
|
|
48
50
|
private selectEngine;
|
|
51
|
+
/**
|
|
52
|
+
* Turn audio attachments into message text. Engines can't consume audio, so it never
|
|
53
|
+
* reaches them; the echo lets the user catch a bad transcription before the agent acts.
|
|
54
|
+
*/
|
|
55
|
+
private transcribeVoice;
|
|
49
56
|
private routeMessage;
|
|
50
57
|
private routeEngineEvent;
|
|
51
58
|
private routeTextChunk;
|
package/dist/orchestrator.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import log from "./logger.js";
|
|
7
7
|
import { saveFile } from "./files.js";
|
|
8
|
+
import { isAudio, transcribeAudio } from "./transcribe.js";
|
|
8
9
|
import { assemblePrompt } from "./prompt.js";
|
|
9
10
|
import { formatPlanUsage, recordPlanUsage } from "./plan-usage.js";
|
|
10
11
|
import { formatToolStatusLine, formatToolCallSummary, formatPermissionPrompt, formatTodoList, timeAgo } from "./format.js";
|
|
@@ -28,10 +29,12 @@ export class Orchestrator {
|
|
|
28
29
|
creatingWorkspaces = new Set();
|
|
29
30
|
scheduler = null;
|
|
30
31
|
planUsage = new Map();
|
|
32
|
+
transcribe;
|
|
31
33
|
constructor(opts) {
|
|
32
34
|
this.channel = opts.channel;
|
|
33
35
|
this.engines = opts.engines;
|
|
34
36
|
this.config = opts.config;
|
|
37
|
+
this.transcribe = opts.transcribe ?? transcribeAudio;
|
|
35
38
|
}
|
|
36
39
|
chat(chatId) {
|
|
37
40
|
let s = this.chats.get(chatId);
|
|
@@ -539,6 +542,33 @@ export class Orchestrator {
|
|
|
539
542
|
await this.channel.sendMessage(msg.chatId, `Engine set to ${selected}.${changed ? " Session cleared." : ""}${handoffNote}`);
|
|
540
543
|
await this.updateStatusMessage(msg.chatId, state);
|
|
541
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* Turn audio attachments into message text. Engines can't consume audio, so it never
|
|
547
|
+
* reaches them; the echo lets the user catch a bad transcription before the agent acts.
|
|
548
|
+
*/
|
|
549
|
+
async transcribeVoice(msg) {
|
|
550
|
+
const attachments = msg.attachments ?? [];
|
|
551
|
+
const rest = attachments.filter((att) => !isAudio(att));
|
|
552
|
+
let spoken;
|
|
553
|
+
try {
|
|
554
|
+
const transcripts = [];
|
|
555
|
+
for (const att of attachments.filter(isAudio))
|
|
556
|
+
transcripts.push(await this.transcribe(att));
|
|
557
|
+
spoken = transcripts.join("\n");
|
|
558
|
+
const echo = spoken.length > 1000 ? `${spoken.slice(0, 1000)}…` : spoken;
|
|
559
|
+
await this.channel.sendMessage(msg.chatId, `🎙 heard: ${echo}`, { format: "plain" })
|
|
560
|
+
.catch((err) => log.warn({ err }, "[orchestrator] failed to echo transcript"));
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
log.warn({ err }, "[orchestrator] voice transcription failed");
|
|
564
|
+
spoken = "[Voice message — transcription failed]";
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
...msg,
|
|
568
|
+
text: msg.text ? `${msg.text}\n${spoken}` : spoken,
|
|
569
|
+
attachments: rest.length ? rest : undefined,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
542
572
|
async routeMessage(msg) {
|
|
543
573
|
try {
|
|
544
574
|
// routeMessage only handles channel-emitted messages, which are always user-originated.
|
|
@@ -547,6 +577,8 @@ export class Orchestrator {
|
|
|
547
577
|
const { user } = msg.origin;
|
|
548
578
|
if (!this.config.isAuthorized(user.id))
|
|
549
579
|
return;
|
|
580
|
+
if (msg.attachments?.some(isAudio))
|
|
581
|
+
msg = await this.transcribeVoice(msg);
|
|
550
582
|
if (this.channel.isRootDM(msg.chatId, user.id) && !this.config.workspaceByChat(msg.chatId)) {
|
|
551
583
|
this.config.connectHomeWorkspace(msg.chatId);
|
|
552
584
|
}
|
package/dist/prompt.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import fsp from "node:fs/promises";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import log from "./logger.js";
|
|
5
4
|
// A cloud-synced instructions dir (OneDrive/iCloud "dataless" placeholder) can
|
|
6
5
|
// block a read indefinitely. Reading it synchronously froze the whole relay.
|
|
7
6
|
const READ_TIMEOUT_MS = 5000;
|
|
8
|
-
/**
|
|
9
|
-
async function
|
|
7
|
+
/** Settle with `op`, or with null once READ_TIMEOUT_MS passes first. */
|
|
8
|
+
async function withReadTimeout(op) {
|
|
10
9
|
// ponytail: Promise.race, not AbortSignal — an abort can't interrupt a read
|
|
11
10
|
// already blocked in libuv's threadpool, which is exactly what a
|
|
12
11
|
// cloud-placeholder file does.
|
|
@@ -15,7 +14,16 @@ async function readFileOrSkip(file) {
|
|
|
15
14
|
timer = setTimeout(() => resolve(null), READ_TIMEOUT_MS);
|
|
16
15
|
});
|
|
17
16
|
try {
|
|
18
|
-
|
|
17
|
+
return await Promise.race([op, timeout]);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Read a file, giving up after READ_TIMEOUT_MS. Returns null on timeout or error. */
|
|
24
|
+
async function readFileOrSkip(file) {
|
|
25
|
+
try {
|
|
26
|
+
const content = await withReadTimeout(fsp.readFile(file, "utf-8"));
|
|
19
27
|
if (content === null) {
|
|
20
28
|
log.warn("[prompt] timed out reading %s after %dms — skipping", file, READ_TIMEOUT_MS);
|
|
21
29
|
return null;
|
|
@@ -26,26 +34,29 @@ async function readFileOrSkip(file) {
|
|
|
26
34
|
log.warn({ err }, "[prompt] could not read %s — skipping", file);
|
|
27
35
|
return null;
|
|
28
36
|
}
|
|
29
|
-
finally {
|
|
30
|
-
clearTimeout(timer);
|
|
31
|
-
}
|
|
32
37
|
}
|
|
33
38
|
/**
|
|
34
39
|
* Read all .md files from a directory, sorted alphabetically.
|
|
35
40
|
* Unreadable files are reported by name instead of silently dropped.
|
|
36
41
|
*/
|
|
37
42
|
async function readMarkdownFiles(dir) {
|
|
38
|
-
if (!fs.existsSync(dir))
|
|
39
|
-
return { contents: [], skipped: [] };
|
|
40
43
|
let entries;
|
|
41
44
|
try {
|
|
42
|
-
|
|
45
|
+
// Listing a cloud-synced dir can block just like reading one (seen with
|
|
46
|
+
// Google Drive under a launchd daemon before macOS granted access).
|
|
47
|
+
entries = await withReadTimeout(fsp.readdir(dir));
|
|
43
48
|
}
|
|
44
49
|
catch (err) {
|
|
50
|
+
if (err.code === "ENOENT")
|
|
51
|
+
return { contents: [], skipped: [] };
|
|
45
52
|
// Cloud-synced dirs (Google Drive File Provider) can transiently refuse a listing with EPERM.
|
|
46
53
|
log.warn({ err }, "[prompt] could not list %s — skipping", dir);
|
|
47
54
|
return { contents: [], skipped: [dir] };
|
|
48
55
|
}
|
|
56
|
+
if (entries === null) {
|
|
57
|
+
log.warn("[prompt] timed out listing %s after %dms — skipping", dir, READ_TIMEOUT_MS);
|
|
58
|
+
return { contents: [], skipped: [dir] };
|
|
59
|
+
}
|
|
49
60
|
const names = entries.filter((f) => f.endsWith(".md")).sort();
|
|
50
61
|
const results = await Promise.all(names.map(async (f) => ({ name: f, text: await readFileOrSkip(path.join(dir, f)) })));
|
|
51
62
|
return {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Attachment } from "./types.js";
|
|
2
|
+
export declare function isAudio(att: Attachment): boolean;
|
|
3
|
+
/** Interim/Text frames replace the current segment; Endpoint finalizes it. */
|
|
4
|
+
export declare function reduceTranscriptFrames(frames: Array<{
|
|
5
|
+
type: string;
|
|
6
|
+
data?: string;
|
|
7
|
+
}>): string;
|
|
8
|
+
/** The service's last word on a stream is an Endpoint; anything else means text may still be coming. */
|
|
9
|
+
export declare function isComplete(frames: Array<{
|
|
10
|
+
type: string;
|
|
11
|
+
}>): boolean;
|
|
12
|
+
/** Speech-to-text via the stream Claude Code's /voice uses, authenticated with the Claude.ai OAuth token. */
|
|
13
|
+
export declare function transcribeAudio(att: Attachment): Promise<string>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const RATE = 16000;
|
|
6
|
+
const FRAME_BYTES = RATE * 2 / 10; // 100 ms of s16le mono
|
|
7
|
+
const STREAM_URL = "wss://api.anthropic.com/api/ws/speech_to_text/voice_stream?" + new URLSearchParams({
|
|
8
|
+
encoding: "linear16", sample_rate: String(RATE), channels: "1", endpointing_ms: "300", utterance_end_ms: "1000",
|
|
9
|
+
language: "en", use_conversation_engine: "true", stt_provider: "deepgram-nova3",
|
|
10
|
+
});
|
|
11
|
+
export function isAudio(att) {
|
|
12
|
+
return att.mimeType.startsWith("audio/");
|
|
13
|
+
}
|
|
14
|
+
/** Interim/Text frames replace the current segment; Endpoint finalizes it. */
|
|
15
|
+
export function reduceTranscriptFrames(frames) {
|
|
16
|
+
const finals = [];
|
|
17
|
+
let current = "";
|
|
18
|
+
for (const f of frames) {
|
|
19
|
+
if (f.type === "TranscriptInterim" || f.type === "TranscriptText")
|
|
20
|
+
current = f.data ?? "";
|
|
21
|
+
else if (f.type === "TranscriptEndpoint") {
|
|
22
|
+
if (current)
|
|
23
|
+
finals.push(current);
|
|
24
|
+
current = "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return [...finals, current].filter(Boolean).join(" ").trim();
|
|
28
|
+
}
|
|
29
|
+
/** The service's last word on a stream is an Endpoint; anything else means text may still be coming. */
|
|
30
|
+
export function isComplete(frames) {
|
|
31
|
+
return frames.at(-1)?.type === "TranscriptEndpoint";
|
|
32
|
+
}
|
|
33
|
+
/** Speech-to-text via the stream Claude Code's /voice uses, authenticated with the Claude.ai OAuth token. */
|
|
34
|
+
export async function transcribeAudio(att) {
|
|
35
|
+
const token = readClaudeToken();
|
|
36
|
+
if (!token)
|
|
37
|
+
throw new Error("no Claude.ai credentials");
|
|
38
|
+
const pcm = await decodeToPcm16k(att.buffer);
|
|
39
|
+
const text = await voiceStream(pcm, token);
|
|
40
|
+
if (!text)
|
|
41
|
+
throw new Error("empty transcript");
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
function readClaudeToken() {
|
|
45
|
+
try {
|
|
46
|
+
const raw = process.platform === "darwin"
|
|
47
|
+
? execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 })
|
|
48
|
+
: fs.readFileSync(path.join(os.homedir(), ".claude", ".credentials.json"), "utf8");
|
|
49
|
+
const oauth = JSON.parse(raw).claudeAiOauth;
|
|
50
|
+
if (!oauth?.accessToken || !(oauth.expiresAt > Date.now()))
|
|
51
|
+
return null;
|
|
52
|
+
return oauth.accessToken;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// The service requires an explicit PCM encoding; it does not accept OGG/Opus.
|
|
59
|
+
function decodeToPcm16k(input) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const ff = spawn("ffmpeg", ["-loglevel", "error", "-i", "pipe:0", "-f", "s16le", "-ac", "1", "-ar", String(RATE), "pipe:1"], { timeout: 30_000 });
|
|
62
|
+
const out = [];
|
|
63
|
+
let stderr = "";
|
|
64
|
+
ff.stdout.on("data", (c) => out.push(c));
|
|
65
|
+
ff.stderr.on("data", (c) => (stderr += c));
|
|
66
|
+
ff.on("error", (e) => reject(new Error(`ffmpeg failed: ${e.message}`)));
|
|
67
|
+
ff.on("close", (code) => {
|
|
68
|
+
const pcm = Buffer.concat(out);
|
|
69
|
+
if (code === 0 && pcm.length > 0)
|
|
70
|
+
resolve(pcm);
|
|
71
|
+
else
|
|
72
|
+
reject(new Error(`ffmpeg failed: ${stderr.trim().split("\n").pop() || `exit ${code}`}`));
|
|
73
|
+
});
|
|
74
|
+
ff.stdin.on("error", () => { }); // ffmpeg can exit before reading all input; "close" reports why
|
|
75
|
+
ff.stdin.end(input);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function voiceStream(pcm, token) {
|
|
79
|
+
// The service trims ~0.5 s at each end; pad with silence so the first and last words survive.
|
|
80
|
+
const pad = Buffer.alloc(RATE); // 0.5 s
|
|
81
|
+
const audio = Buffer.concat([pad, pcm, pad]);
|
|
82
|
+
const durationMs = (pcm.length / 2 / RATE) * 1000;
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const ws = new WebSocket(STREAM_URL, { headers: {
|
|
85
|
+
Authorization: `Bearer ${token}`, "User-Agent": "claude-cli/2.1.284 (external, cli)",
|
|
86
|
+
"x-app": "cli", "anthropic-client-platform": "claude_code_cli",
|
|
87
|
+
} }); // Node (undici) accepts { headers }; the DOM typings do not
|
|
88
|
+
ws.binaryType = "arraybuffer";
|
|
89
|
+
const frames = [];
|
|
90
|
+
let closing = false;
|
|
91
|
+
let done = false;
|
|
92
|
+
let keepalive;
|
|
93
|
+
let idle;
|
|
94
|
+
let safety;
|
|
95
|
+
const finish = (err) => {
|
|
96
|
+
if (done)
|
|
97
|
+
return;
|
|
98
|
+
done = true;
|
|
99
|
+
clearInterval(keepalive);
|
|
100
|
+
clearTimeout(idle);
|
|
101
|
+
clearTimeout(safety);
|
|
102
|
+
try {
|
|
103
|
+
ws.close();
|
|
104
|
+
}
|
|
105
|
+
catch { /* already closed */ }
|
|
106
|
+
if (err)
|
|
107
|
+
reject(err);
|
|
108
|
+
else
|
|
109
|
+
resolve(reduceTranscriptFrames(frames));
|
|
110
|
+
};
|
|
111
|
+
// Finish on idle rather than on the first Endpoint after CloseStream: with unpaced
|
|
112
|
+
// sending, that endpoint can arrive before the tail of the audio is transcribed.
|
|
113
|
+
// A partial transcript is never a success: an incomplete stream waits for the safety timeout.
|
|
114
|
+
const bump = () => {
|
|
115
|
+
if (!closing)
|
|
116
|
+
return;
|
|
117
|
+
clearTimeout(idle);
|
|
118
|
+
idle = setTimeout(() => { if (isComplete(frames))
|
|
119
|
+
finish(); }, 1500);
|
|
120
|
+
};
|
|
121
|
+
safety = setTimeout(() => finish(new Error("timed out")), 15_000 + durationMs);
|
|
122
|
+
ws.onopen = () => {
|
|
123
|
+
const keepAlive = () => ws.send(JSON.stringify({ type: "KeepAlive" }));
|
|
124
|
+
keepAlive();
|
|
125
|
+
keepalive = setInterval(keepAlive, 8000);
|
|
126
|
+
for (let off = 0; off < audio.length; off += FRAME_BYTES)
|
|
127
|
+
ws.send(audio.subarray(off, off + FRAME_BYTES));
|
|
128
|
+
closing = true;
|
|
129
|
+
ws.send(JSON.stringify({ type: "CloseStream" }));
|
|
130
|
+
bump();
|
|
131
|
+
};
|
|
132
|
+
ws.onmessage = (ev) => {
|
|
133
|
+
let msg;
|
|
134
|
+
try {
|
|
135
|
+
msg = JSON.parse(typeof ev.data === "string" ? ev.data : Buffer.from(ev.data).toString());
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
bump();
|
|
141
|
+
if (msg.type === "TranscriptError" || msg.type === "error") {
|
|
142
|
+
finish(new Error(`voice stream error: ${JSON.stringify(msg.data ?? msg).slice(0, 200)}`));
|
|
143
|
+
}
|
|
144
|
+
else if (msg.type) {
|
|
145
|
+
frames.push({ type: msg.type, data: typeof msg.data === "string" ? msg.data : undefined });
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
ws.onerror = () => finish(new Error("voice stream error: connection failed"));
|
|
149
|
+
ws.onclose = (ev) => {
|
|
150
|
+
if (closing && isComplete(frames))
|
|
151
|
+
finish();
|
|
152
|
+
else
|
|
153
|
+
finish(new Error(`voice stream error: closed ${ev.code} ${ev.reason}`.trim()));
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=transcribe.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clearclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Your coding agent, from your phone — personal assistant + project workspaces on your machine, built on the CLI's permissions, config, and memory instead of replacing them.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|