@astra-code/astra-ai 0.1.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.
@@ -0,0 +1,178 @@
1
+ import {spawn, spawnSync} from "child_process";
2
+ import {randomUUID} from "crypto";
3
+ import {basename, join} from "path";
4
+ import {tmpdir} from "os";
5
+ import {readFile, rm} from "fs/promises";
6
+
7
+ const VOICE_TEXT_LIMIT = 600;
8
+ const DEFAULT_STT_MODEL = process.env.ASTRA_STT_MODEL?.trim() || "whisper-1";
9
+ const DEFAULT_CHUNK_SECONDS = Number(process.env.ASTRA_STT_CHUNK_SECONDS ?? "2.5");
10
+
11
+ const safeText = (text: string): string => text.replace(/\s+/g, " ").trim().slice(0, VOICE_TEXT_LIMIT);
12
+
13
+ const runShell = async (command: string): Promise<void> =>
14
+ new Promise((resolve, reject) => {
15
+ const child = spawn(command, {shell: true, stdio: "ignore"});
16
+ child.on("error", reject);
17
+ child.on("exit", (code) => {
18
+ if (code === 0) {
19
+ resolve();
20
+ } else {
21
+ reject(new Error(`Command failed (${code}): ${command}`));
22
+ }
23
+ });
24
+ });
25
+
26
+ const captureAudioChunk = async (seconds: number): Promise<string> => {
27
+ const outPath = join(tmpdir(), `astra-stt-${randomUUID()}.wav`);
28
+ const custom = process.env.ASTRA_STT_CAPTURE_COMMAND?.trim();
29
+
30
+ let cmd = custom ?? "";
31
+ if (!cmd) {
32
+ if (process.platform === "darwin") {
33
+ cmd = `sox -q -d -r 16000 -c 1 -b 16 "${outPath}" trim 0 ${seconds}`;
34
+ } else if (process.platform === "linux") {
35
+ cmd = `arecord -q -f S16_LE -r 16000 -c 1 -d ${Math.ceil(seconds)} "${outPath}"`;
36
+ } else {
37
+ throw new Error("No default audio capture command for this platform. Set ASTRA_STT_CAPTURE_COMMAND.");
38
+ }
39
+ } else {
40
+ cmd = cmd.replaceAll("{output}", outPath).replaceAll("{seconds}", String(seconds));
41
+ }
42
+
43
+ await runShell(cmd);
44
+ return outPath;
45
+ };
46
+
47
+ const transcribeAudioFile = async (filePath: string): Promise<string> => {
48
+ const apiKey = process.env.ASTRA_OPENAI_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim();
49
+ if (!apiKey) {
50
+ throw new Error("Missing OPENAI_API_KEY (or ASTRA_OPENAI_API_KEY) for Whisper STT.");
51
+ }
52
+
53
+ const bytes = await readFile(filePath);
54
+ const file = new File([bytes], basename(filePath), {type: "audio/wav"});
55
+ const form = new FormData();
56
+ form.append("file", file);
57
+ form.append("model", DEFAULT_STT_MODEL);
58
+ form.append("response_format", "json");
59
+
60
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
61
+ method: "POST",
62
+ headers: {Authorization: `Bearer ${apiKey}`},
63
+ body: form
64
+ });
65
+
66
+ if (!response.ok) {
67
+ const detail = (await response.text()).slice(0, 400);
68
+ throw new Error(`Whisper STT failed ${response.status}: ${detail}`);
69
+ }
70
+
71
+ const data = (await response.json()) as {text?: string};
72
+ return String(data.text ?? "").trim();
73
+ };
74
+
75
+ /**
76
+ * Speak assistant text using either:
77
+ * - ASTRA_TTS_COMMAND (shell command, receives text as final arg), or
78
+ * - macOS `say` by default.
79
+ */
80
+ export const speakText = (text: string): void => {
81
+ const clean = safeText(text);
82
+ if (!clean) {
83
+ return;
84
+ }
85
+
86
+ const custom = process.env.ASTRA_TTS_COMMAND?.trim();
87
+ if (custom) {
88
+ spawn(custom, [clean], {shell: true, detached: true, stdio: "ignore"}).unref();
89
+ return;
90
+ }
91
+
92
+ if (process.platform === "darwin") {
93
+ spawn("say", [clean], {detached: true, stdio: "ignore"}).unref();
94
+ }
95
+ };
96
+
97
+ /**
98
+ * Run one-shot STT. Uses custom ASTRA_STT_COMMAND if provided;
99
+ * otherwise records a short chunk and transcribes via OpenAI Whisper.
100
+ */
101
+ export const transcribeOnce = async (): Promise<string | null> => {
102
+ const sttCommand = process.env.ASTRA_STT_COMMAND?.trim();
103
+ if (sttCommand) {
104
+ const result = spawnSync(sttCommand, {shell: true, encoding: "utf-8"});
105
+ if (result.error || result.status !== 0) {
106
+ return null;
107
+ }
108
+ const out = String(result.stdout ?? "").trim();
109
+ return out || null;
110
+ }
111
+
112
+ let audioPath: string | null = null;
113
+ try {
114
+ audioPath = await captureAudioChunk(DEFAULT_CHUNK_SECONDS);
115
+ const text = await transcribeAudioFile(audioPath);
116
+ return text || null;
117
+ } catch {
118
+ return null;
119
+ } finally {
120
+ if (audioPath) {
121
+ await rm(audioPath, {force: true});
122
+ }
123
+ }
124
+ };
125
+
126
+ export type LiveTranscriptionController = {
127
+ stop: () => Promise<void>;
128
+ };
129
+
130
+ export const startLiveTranscription = (handlers: {
131
+ onPartial: (text: string) => void;
132
+ onFinal: (text: string) => void;
133
+ onError: (error: Error) => void;
134
+ }): LiveTranscriptionController => {
135
+ let running = true;
136
+ let stopping = false;
137
+ let transcript = "";
138
+ let finishedResolve: (() => void) | null = null;
139
+ const finished = new Promise<void>((resolve) => {
140
+ finishedResolve = resolve;
141
+ });
142
+
143
+ const loop = async (): Promise<void> => {
144
+ while (running) {
145
+ let audioPath: string | null = null;
146
+ try {
147
+ audioPath = await captureAudioChunk(DEFAULT_CHUNK_SECONDS);
148
+ const piece = await transcribeAudioFile(audioPath);
149
+ if (piece) {
150
+ transcript = transcript ? `${transcript} ${piece}` : piece;
151
+ handlers.onPartial(transcript);
152
+ }
153
+ } catch (error) {
154
+ handlers.onError(error instanceof Error ? error : new Error(String(error)));
155
+ } finally {
156
+ if (audioPath) {
157
+ await rm(audioPath, {force: true});
158
+ }
159
+ }
160
+ }
161
+ handlers.onFinal(transcript.trim());
162
+ finishedResolve?.();
163
+ };
164
+
165
+ void loop();
166
+
167
+ return {
168
+ stop: async () => {
169
+ if (stopping) {
170
+ await finished;
171
+ return;
172
+ }
173
+ stopping = true;
174
+ running = false;
175
+ await finished;
176
+ }
177
+ };
178
+ };
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Scans a local workspace directory to produce:
3
+ * workspaceTree — relative paths the backend uses for list_directory / search_files
4
+ * workspaceFiles — file contents the backend populates into VirtualFS
5
+ * so view_file / edit_file work without a local disk on the backend
6
+ */
7
+ import {existsSync, readdirSync, readFileSync, statSync} from "fs";
8
+ import {join, relative} from "path";
9
+
10
+ export type WorkspaceFile = {
11
+ path: string;
12
+ content: string;
13
+ language: string;
14
+ };
15
+
16
+ const SKIP_DIRS = new Set([
17
+ "node_modules",
18
+ ".git",
19
+ ".svn",
20
+ ".hg",
21
+ "dist",
22
+ "build",
23
+ ".next",
24
+ ".nuxt",
25
+ "__pycache__",
26
+ ".cache",
27
+ "coverage",
28
+ ".nyc_output",
29
+ "vendor",
30
+ ".venv",
31
+ "venv",
32
+ "env",
33
+ ".tox",
34
+ "target",
35
+ ".gradle",
36
+ ".idea",
37
+ ".pytest_cache",
38
+ ".mypy_cache",
39
+ ".ruff_cache",
40
+ ]);
41
+
42
+ const BINARY_EXTENSIONS = new Set([
43
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp",
44
+ ".woff", ".woff2", ".ttf", ".eot", ".otf",
45
+ ".mp3", ".mp4", ".wav", ".webm", ".ogg", ".flac",
46
+ ".zip", ".tar", ".gz", ".br", ".zst", ".bz2", ".7z", ".rar",
47
+ ".pyc", ".pyo", ".class", ".o", ".so", ".dll", ".dylib",
48
+ ".exe", ".bin", ".pdf", ".sqlite", ".db",
49
+ ]);
50
+
51
+ const EXT_TO_LANG: Record<string, string> = {
52
+ ".py": "python",
53
+ ".js": "javascript",
54
+ ".ts": "typescript",
55
+ ".tsx": "typescriptreact",
56
+ ".jsx": "javascriptreact",
57
+ ".json": "json",
58
+ ".md": "markdown",
59
+ ".css": "css",
60
+ ".scss": "scss",
61
+ ".less": "less",
62
+ ".html": "html",
63
+ ".xml": "xml",
64
+ ".rs": "rust",
65
+ ".go": "go",
66
+ ".java": "java",
67
+ ".c": "c",
68
+ ".cpp": "cpp",
69
+ ".h": "c",
70
+ ".rb": "ruby",
71
+ ".sh": "shellscript",
72
+ ".bash": "shellscript",
73
+ ".zsh": "shellscript",
74
+ ".yaml": "yaml",
75
+ ".yml": "yaml",
76
+ ".toml": "toml",
77
+ ".sql": "sql",
78
+ ".swift": "swift",
79
+ ".kt": "kotlin",
80
+ ".php": "php",
81
+ ".graphql": "graphql",
82
+ ".vue": "vue",
83
+ ".svelte": "svelte",
84
+ ".env": "plaintext",
85
+ ".txt": "plaintext",
86
+ ".csv": "plaintext",
87
+ ".lock": "plaintext",
88
+ };
89
+
90
+ const MAX_TREE_ENTRIES = 2000;
91
+ const MAX_CONTENT_FILES = 200;
92
+ const MAX_FILE_BYTES = 100 * 1024; // 100 KB per file
93
+ const MAX_TOTAL_BYTES = 2 * 1024 * 1024; // 2 MB total content
94
+
95
+ function detectLanguage(relPath: string): string {
96
+ const m = relPath.toLowerCase().match(/\.[^./\\]+$/);
97
+ return m ? (EXT_TO_LANG[m[0]] ?? "plaintext") : "plaintext";
98
+ }
99
+
100
+ function isBinary(relPath: string): boolean {
101
+ const m = relPath.toLowerCase().match(/\.[^./\\]+$/);
102
+ return m ? BINARY_EXTENSIONS.has(m[0]) : false;
103
+ }
104
+
105
+ /**
106
+ * Scan `rootDir` and return both the path tree and file contents.
107
+ *
108
+ * Called once per `streamChat` request so the backend VirtualFS is always
109
+ * populated with the current state of the local workspace.
110
+ */
111
+ export function scanWorkspace(rootDir: string): {
112
+ workspaceTree: string[];
113
+ workspaceFiles: WorkspaceFile[];
114
+ } {
115
+ if (!existsSync(rootDir)) {
116
+ return {workspaceTree: [], workspaceFiles: []};
117
+ }
118
+
119
+ const workspaceTree: string[] = [];
120
+ const workspaceFiles: WorkspaceFile[] = [];
121
+ let totalBytes = 0;
122
+
123
+ function walk(dir: string): void {
124
+ if (workspaceTree.length >= MAX_TREE_ENTRIES) return;
125
+
126
+ let entries: string[];
127
+ try {
128
+ entries = readdirSync(dir).sort();
129
+ } catch {
130
+ return;
131
+ }
132
+
133
+ for (const entry of entries) {
134
+ if (workspaceTree.length >= MAX_TREE_ENTRIES) break;
135
+
136
+ const fullPath = join(dir, entry);
137
+ const relPath = relative(rootDir, fullPath).replace(/\\/g, "/");
138
+
139
+ let st;
140
+ try {
141
+ st = statSync(fullPath);
142
+ } catch {
143
+ continue;
144
+ }
145
+
146
+ if (st.isDirectory()) {
147
+ if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
148
+ workspaceTree.push(relPath + "/");
149
+ walk(fullPath);
150
+ } else if (st.isFile()) {
151
+ workspaceTree.push(relPath);
152
+
153
+ if (
154
+ workspaceFiles.length < MAX_CONTENT_FILES &&
155
+ totalBytes < MAX_TOTAL_BYTES &&
156
+ !isBinary(relPath) &&
157
+ st.size < MAX_FILE_BYTES
158
+ ) {
159
+ try {
160
+ const content = readFileSync(fullPath, "utf-8");
161
+ workspaceFiles.push({
162
+ path: relPath,
163
+ content,
164
+ language: detectLanguage(relPath),
165
+ });
166
+ totalBytes += st.size;
167
+ } catch {
168
+ // Unreadable file — include in tree but skip content.
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ walk(rootDir);
176
+ return {workspaceTree, workspaceFiles};
177
+ }
@@ -0,0 +1,33 @@
1
+ export type AuthSession = {
2
+ user_id: string;
3
+ org_id: string;
4
+ email?: string;
5
+ [key: string]: unknown;
6
+ };
7
+
8
+ export type ChatMessage = {
9
+ role: "user" | "assistant";
10
+ content: string;
11
+ };
12
+
13
+ export type AgentEvent =
14
+ | {type: "text"; content?: string}
15
+ | {type: "thinking"; content?: string}
16
+ | {type: "tool_start"; tool?: {name?: string}}
17
+ | {
18
+ type: "tool_result";
19
+ success?: boolean;
20
+ tool_name?: string;
21
+ data?: Record<string, unknown>;
22
+ error?: string;
23
+ }
24
+ | {
25
+ type: "run_in_terminal";
26
+ terminal_id?: string;
27
+ command?: string;
28
+ cwd?: string;
29
+ blocking?: boolean;
30
+ }
31
+ | {type: "credits_update"; remaining?: number; cost?: number}
32
+ | {type: "credits_exhausted"; message?: string}
33
+ | {type: "error"; error?: string; content?: string};
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "rootDir": "src",
4
+ "outDir": "dist",
5
+ "module": "nodenext",
6
+ "target": "es2022",
7
+ "types": ["node"],
8
+ "sourceMap": true,
9
+ "declaration": false,
10
+ "noUncheckedIndexedAccess": true,
11
+ "exactOptionalPropertyTypes": true,
12
+ "strict": true,
13
+ "jsx": "react-jsx",
14
+ "verbatimModuleSyntax": true,
15
+ "isolatedModules": true,
16
+ "noUncheckedSideEffectImports": true,
17
+ "moduleDetection": "force",
18
+ "skipLibCheck": true,
19
+ "esModuleInterop": true,
20
+ "resolveJsonModule": true,
21
+ "moduleResolution": "nodenext"
22
+ },
23
+ "include": ["src"]
24
+ }